startBatch.mutate() }}>
New side quests arrive Sunday.
@@ -110,21 +174,15 @@ export function DiscoverPage() {
))}
-
-
+ decide('pass')}>
+
+
+ decide('try')}>
+
+
{decision.isError && (
- mutateDecision(decision.variables!)}>
+ submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
)}
@@ -139,7 +197,7 @@ export function DiscoverPage() {
Discover
Side quests
- {questLog.isSuccess && Quest log · {questLog.data}}
+ {questLog.isSuccess && }
{questLog.isError && (
void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}
diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts
index 0773611..b0a0a77 100644
--- a/web/src/discover/api.ts
+++ b/web/src/discover/api.ts
@@ -32,6 +32,28 @@ export type Batch = { batch_id: string; job_id: string }
// BatchResult is the suggestion batch job's result, published as it goes.
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
+// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
+export type QuestLogDinner = { id: string; name: string }
+
+// DECISION_STATUS is the status a suggestion has once each choice is saved.
+const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' }
+
+// parseBatchResult checks a suggestion batch job's result, which useJob
+// passes on unread.
+export function parseBatchResult(where: string, body: unknown): BatchResult {
+ if (
+ !isRecord(body) ||
+ typeof body.batch_id !== 'string' ||
+ typeof body.stage !== 'string' ||
+ !Array.isArray(body.suggestion_ids) ||
+ !body.suggestion_ids.every((id) => typeof id === 'string') ||
+ typeof body.dropped !== 'number'
+ ) {
+ throw new Error(`${where}: result is not a suggestion batch`)
+ }
+ return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped }
+}
+
function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
@@ -65,16 +87,22 @@ export function useDeck() {
})
}
-// useQuestLogCount is how many dinners are in the Quest log (GET
+// useQuestLog is the dinners in the Quest log, in the server's order (GET
// /api/v1/dinners?status=quest_log).
-export function useQuestLogCount() {
+export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
queryKey: QUEST_LOG_KEY,
- queryFn: async () => {
+ queryFn: async (): Promise => {
+ const where = `GET ${QUEST_LOG_PATH}`
const body = await apiFetch(QUEST_LOG_PATH)
- if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`GET ${QUEST_LOG_PATH}: response has no dinners list`)
- return body.dinners.length
+ if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
+ return body.dinners.map((dinner) => {
+ if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
+ throw new Error(`${where}: response is not a dinner`)
+ }
+ return { id: dinner.id, name: dinner.name }
+ })
},
})
}
@@ -97,14 +125,23 @@ export function useStartBatch() {
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
-// Quest log again.
+// Quest log again. The answer must be that suggestion, decided that way.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
const action = choice === 'try' ? 'accept' : 'decline'
- await apiFetch(`${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
+ const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
+ const body = await apiFetch(path, { method: 'POST' })
+ if (
+ !isRecord(body) ||
+ !isRecord(body.suggestion) ||
+ body.suggestion.id !== id ||
+ body.suggestion.status !== DECISION_STATUS[choice]
+ ) {
+ throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
+ }
},
onSuccess: async () => {
await Promise.all([
diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css
index 7dd0c66..2995110 100644
--- a/web/src/ui/IconButton.module.css
+++ b/web/src/ui/IconButton.module.css
@@ -29,6 +29,20 @@
height: 62px;
}
+/* A shown label sits 6 px under the circle, and the button grows to hold it. */
+.labelled {
+ flex-direction: column;
+ gap: 6px;
+ width: auto;
+ min-width: 62px;
+ height: auto;
+ min-height: 62px;
+ color: var(--color-neutral-700);
+ font-family: var(--font-body);
+ font-size: 11px;
+ font-weight: 700;
+}
+
.face {
display: inline-flex;
align-items: center;
@@ -57,6 +71,11 @@
color: var(--color-text);
}
+.neutral {
+ background: var(--color-neutral-100);
+ color: var(--color-text);
+}
+
.accent {
background: var(--color-accent);
color: var(--on-accent);
diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx
index 0341c1a..e909cdd 100644
--- a/web/src/ui/IconButton.test.tsx
+++ b/web/src/ui/IconButton.test.tsx
@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
+import { declaredStyle } from '../test/cssRules.ts'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
@@ -31,4 +32,21 @@ describe('IconButton', () => {
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
+
+ it('shows its label as an 11 px caption under the circle, which names it', () => {
+ render(
+
+
+ ,
+ )
+ const button = screen.getByRole('button', { name: 'Pass' })
+ expect(button).not.toHaveAttribute('aria-label')
+ expect(button).toHaveTextContent('Pass')
+ expect(getComputedStyle(button).fontSize).toBe('11px')
+ expect(getComputedStyle(button).flexDirection).toBe('column')
+ expectMinHitArea(button)
+ const face = button.firstElementChild!
+ expect(hitArea(face)).toEqual({ width: 62, height: 62 })
+ expect(declaredStyle(face).background).toBe('var(--color-neutral-100)')
+ })
})
diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx
index 1a5ab73..6da1b22 100644
--- a/web/src/ui/IconButton.tsx
+++ b/web/src/ui/IconButton.tsx
@@ -2,32 +2,38 @@ import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
-export type IconButtonTone = 'surface' | 'accent' | 'teal'
+export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
export type IconButtonProps = Omit, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
+ // Shows the label as an 11 px caption under the circle, which then names
+ // the button.
+ showLabel?: boolean
}
-// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
-// the 62 px size is the round action button with --shadow-md.
+// Round icon button, named by its label and showing it only with showLabel.
+// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
+// round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
+ showLabel = false,
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
- const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
+ const classes = [styles.button, styles[`size${size}`], showLabel && styles.labelled, className].filter(Boolean).join(' ')
return (
-