Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 6fd607f125 Keep a Side quest off the shown deck while its decision is pending
CI / ci (push) Successful in 2m55s
CI / ci (pull_request) Successful in 2m37s
The deck shown is now the deck's newest read less the Side quests whose
decisions are in flight, taken from the pending decide mutations, instead
of removing the card from the deck cache in onMutate. A read of the deck
landing before the decision is answered can no longer bring the exited
card back; settlement still reconciles from the server read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:57:53 -04:00
dev_agentandClaude Opus 5.5 701021d327 Settle a failed Discover decision on the deck's newest read
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m38s
A failed decision still waits for the deck read it makes itself, but no
longer carries that read's answer on its error. Whether its Side quest is
still pending is now asked of the deck query's newest successful read
(stillPending), both when the decision settles and when the page offers
Retry. A read that lands later and has the Side quest decided keeps it off
the deck and withdraws the Retry; a deck whose latest read failed offers no
Retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:45:51 -04:00
dev_agentandClaude Opus 5.5 8b18399710 Settle a failed Discover decision on the deck read it makes itself
CI / ci (push) Successful in 2m58s
CI / ci (pull_request) Successful in 2m39s
A failed decision now reads the deck as its own fetch and waits for it,
and that read's answer travels on the DecisionError: still pending puts
the Side quest back on top with a Retry, decided shows the informational
banner, and a failed read shows the error without Retry. The page no
longer classifies from whatever the deck cache holds.

A batch poll's deck read no longer cancels a read already out (which may
be a decision's): it waits for it, then reads again, as that read may
predate what the batch reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:36:19 -04:00
dev_agentandClaude Opus 5.5 f26cfd5129 Reconcile every Discover decision from the server before the next
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m38s
Replace the hand-maintained optimistic bookkeeping in useDecide with one
rule: a decision takes its card off the deck (and, on Try it, adds its
dinner to the Quest log) when the exit finishes; however it settles, the
deck and the Quest log are read again and the decision stays pending
until those reads land, so the input lock keeps the next decision out.
No decision cancels a read.

A failed decision whose Side quest the reconciled deck still has goes
back on top with a Retry; otherwise the banner says it was decided
already, with no Retry. ErrorBanner's Retry is now optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:21:54 -04:00
dev_agentandClaude Opus 5.5 3319434bd1 Reconcile the deck after a decision failure that may not leave the card pending
CI / ci (push) Successful in 2m55s
CI / ci (pull_request) Successful in 2m39s
Only an API error that refuses the request itself (a 4xx other than 404
or 409) puts a failed Side quest back on top of the deck. A 409 (decided
by another session or no longer suggested), a 404, a 5xx or a failure
without an answer now reads the deck again instead, keeping the card off
it until that read answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:10:39 -04:00
dev_agentandClaude Opus 5.5 1c59e5d9d7 Keep a decided Side quest off the deck after an uncertain decision failure
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m38s
A decision now follows the reads of the deck that land while it is in
flight. When it fails, the Side quest is put back on top of the deck only
if no such read has left it out, so a card the server already decided is
not offered again.

A failure that is not an API error answer, such as a lost response, may
have come after the server saved the decision, so the deck is read again
to reconcile it. An API error answer still puts the card back on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:02:04 -04:00
dev_agentandClaude Opus 5.5 0ea1c218ea Roll back only the decision's own Quest log entry and send stale-card decisions
CI / ci (push) Failing after 1m55s
CI / ci (pull_request) Successful in 2m43s
A refused Try it now removes the Quest log entry it tagged when it added
it, so a read of the Quest log that landed during the decision is kept,
and reads the Quest log again since the decision may have been saved.

A decision is sent for the card thrown out even when a read of the deck
took it off during the exit, so Retry on that refusal posts the decision
again instead of failing before sending it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:53:09 -04:00
dev_agentandClaude Opus 5.5 6c105416a9 Keep decided Side quests off the deck and show refused decisions
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m38s
A saved decision now reads the deck again whenever a read started before
the save is still under way, so a late answer can no longer put the
decided Side quest back. Optimistic Try it adds its dinner to the Quest
log only when it is not there yet, and a refusal removes only that
addition. A refused decision's ErrorBanner with Retry now shows whatever
the deck has come to, including when a read emptied it during the exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:43:06 -04:00
dev_agentandClaude Opus 5.5 74611da62b Apply Discover decisions optimistically and refill the deck
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m38s
Try it and Pass are applied when the card's exit finishes: the Side
quest leaves the deck and, on Try it, its dinner joins the Quest log
count before the server answers. A refused decision puts the card back
on top of the deck, reverts the count and shows the ErrorBanner with
Retry. A saved decision reads the deck again once fewer than 3 cards are
left, and follows the replenish job it started, showing skeleton cards
rather than "All caught up" while that job runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:31:55 -04:00
orchestrator-agent 9088c313cc Swipe gesture on the Discover deck (#107)
CI / ci (push) Successful in 2m37s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 02:18:40 -04:00
9 changed files with 1574 additions and 84 deletions
+15 -4
View File
@@ -1,3 +1,4 @@
import type { DOMAttributes } from 'react'
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
import { Photo } from '../ui/Photo.tsx'
import type { SideQuest } from './api.ts'
@@ -8,25 +9,35 @@ export const DECK_DEPTH = [0, 1, 2] as const
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// SwipeHandlers are the pointer handlers that drag the top card.
export type SwipeHandlers = Pick<
DOMAttributes<HTMLElement>,
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
>
export type DeckCardProps = {
quest: SideQuest
// 0 is the top card; 1 and 2 stack behind it.
depth: number
// swipe makes the card draggable; only the top card gets it.
swipe?: SwipeHandlers
}
// DeckCard is one Side quest in the deck. Only the top card is read out and
// reachable; the cards behind it are the stack's edge, kept rendered so they
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
// decision shows them, and its shade until Pass folds it (see motion.ts).
export function DeckCard({ quest, depth }: DeckCardProps) {
// decision or a drag shows them, and its shade until Pass folds it (see
// motion.ts).
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}`}
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
{...swipe}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
@@ -41,7 +52,7 @@ export function DeckCard({ quest, depth }: DeckCardProps) {
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
Pass
</span>
<span className={styles.shade} data-part="shade" />
+6
View File
@@ -104,6 +104,12 @@
box-shadow: var(--shadow-lg);
}
/* The top card follows a drag instead of scrolling the page. */
.draggable {
touch-action: none;
cursor: grab;
}
.depth1 {
z-index: 2;
transform: translateY(8px) scale(0.95);
File diff suppressed because it is too large Load Diff
+176 -40
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react'
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -12,16 +12,39 @@ import {
BATCH_JOB_KIND,
DECK_KEY,
parseBatchResult,
stillPending,
useDeciding,
useDeck,
useDecide,
useQuestLog,
useStartBatch,
type Choice,
type SideQuest,
} from './api.ts'
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
import { bump, fadeOut, isAbort, pass, prefersReducedMotion, promote, tryIt, type Exit } from './motion.ts'
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
import {
bump,
fadeOut,
follow,
isAbort,
pass,
prefersReducedMotion,
promote,
snapBack,
tryIt,
unfollow,
type Exit,
} from './motion.ts'
import styles from './DiscoverPage.module.css'
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
// right for its release to Pass or Try it.
const SWIPE_THRESHOLD = 110
// A Drag is the pointer dragging the top card: where it went down, and the
// card's drag transform so far.
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
// isEditable says whether a key press belongs to a field being typed in,
// which the deck's ← / → keys leave alone.
function isEditable(target: EventTarget | null): boolean {
@@ -66,34 +89,46 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
}
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. A decision from the buttons or the ← / → keys
// throws the top card out (motion.ts) and sends the decision once it is
// gone; Retry sends a refused one again. Input is locked from the press
// until the decision settles: the lock is a ref, set before anything
// starts, so a second press before the next render does nothing, and busy
// mirrors it to disable the buttons.
// at a time from the top card. A decision from the buttons, the ← / → keys
// or a swipe of the top card throws it out (motion.ts) and sends the
// decision once it is gone, taking the card off the deck at once (see
// useDecide); Retry sends a failed one again while the deck's newest read
// still has its Side quest pending. Input is locked from the press
// until the decision settles, which is once the deck and the Quest log have
// been read again after it, so no decision starts under those reads. The lock is a
// ref, set before anything starts, so a second press before the next render
// does nothing, and busy mirrors it to disable the buttons.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
// The deck shown: the deck's newest read, less the Side quests being
// decided, which a read landing mid-decision may still have pending.
const inFlight = useDeciding()
const cards = deck.data?.filter((quest) => !inFlight.has(quest.id))
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
// The job of the batch last known to be finding Side quests: the one Get
// more now started or joined, or the replenish a decision started.
const [batchJobId, setBatchJobId] = useState<string | null>(null)
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
const top = deck.data?.[0]
const top = cards?.[0]
const [busy, setBusy] = useState(false)
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
const { mutate: mutateStartBatch } = startBatch
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
// The batch's result as far as it has gone, or why it cannot be read.
const batch = useMemo(() => {
if (job.result === null) return { stored: 0, error: null }
try {
return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null }
return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null }
} catch (error) {
return { stored: 0, error: error as Error }
}
}, [job.result, startBatch.data?.job_id])
}, [job.result, batchJobId])
// The job's failure, the latest poll's failure, an answer that is not the
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
@@ -102,10 +137,18 @@ export function DiscoverPage() {
const passRef = useRef<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(null)
// The top card's exit while it runs or its decision is unsettled, and the
// Side quest the last decision sent was for, until the deck moves past it.
// The top card's exit until its decision settles, and the Side quest the
// last decision sent was for, until the deck moves past it.
const exit = useRef<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(null)
// The top card's snap-back while it may still run; a new drag or a
// decision stops it so it does not fight the card's new motion.
const springing = useRef<Animation | null>(null)
const stopSpring = useCallback(() => {
springing.current?.cancel()
springing.current = null
}, [])
const { mutate: mutateDecision } = decision
const deciding = useRef(false)
@@ -115,17 +158,22 @@ export function DiscoverPage() {
setBusy(true)
return true
}, [])
// send records a decision. A refused one puts the card back.
// send records a decision. A failed one stops the card's exit, so a card
// useDecide put back on the deck is at rest; a saved one that started a
// replenish batch follows its job.
const send = useCallback(
(variables: { id: string; choice: Choice }) => {
leaving.current = variables.id
(variables: { quest: SideQuest; choice: Choice }) => {
leaving.current = variables.quest.id
mutateDecision(variables, {
onSuccess: (replenishJobId) => {
if (replenishJobId !== null) setBatchJobId(replenishJobId)
},
onError: () => {
exit.current?.cancel()
exit.current = null
leaving.current = null
},
onSettled: () => {
exit.current = null
deciding.current = false
setBusy(false)
},
@@ -134,50 +182,122 @@ export function DiscoverPage() {
[mutateDecision],
)
const submit = useCallback(
(variables: { id: string; choice: Choice }) => {
(variables: { quest: SideQuest; choice: Choice }) => {
if (lock()) send(variables)
},
[lock, send],
)
// decide bumps the button, throws the top card out — or, motion reduced,
// fades it — then sends the decision; Try it bumps the Quest log pill too.
// A swipe's decision comes from the card's drag transform: it is thrown
// from there without a bump or wiggle. A button or key decision ends a
// drag in progress, putting the card back first.
const decide = useCallback(
(choice: Choice) => {
(choice: Choice, from?: string) => {
if (top === undefined || !lock()) return
stopSpring()
const card = deckRef.current!.querySelector('article')!
if (from === undefined && drag.current !== null) {
drag.current = null
unfollow(card)
}
const reduced = prefersReducedMotion()
let run: Exit
if (reduced) {
run = fadeOut(card)
} else {
} else if (from === undefined) {
bump((choice === 'try' ? tryRef : passRef).current!)
run = choice === 'try' ? tryIt(card) : pass(card)
} else {
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
}
if (from !== undefined) {
// Putting a swiped card back puts it back at rest.
const thrown = run
run = {
finished: thrown.finished,
cancel: () => {
thrown.cancel()
unfollow(card)
},
}
}
exit.current = run
run.finished.then(
() => {
// The Quest log pill is missing while the Quest log fails to load.
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
send({ id: top.id, choice })
send({ quest: top, choice })
},
(error: unknown) => {
if (!isAbort(error)) throw error
},
)
},
[top, lock, send],
[top, lock, send, stopSpring],
)
// swipe drags the top card: it follows the pointer, captured from the
// press, until the release decides past the threshold or springs it back
// short of it. The release decides from where the pointer let go, which
// may differ from its last move. A cancel, or losing the capture before
// the release, springs it back from its last move. One pointer drags at
// a time: a press while a drag is in progress is ignored, as is input
// while busy, and a drag held into a busy spell (a Retry) is put back on
// release.
const swipe = useMemo<SwipeHandlers>(() => {
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
current.dx = event.clientX - current.x
current.transform = follow(card, current.dx, event.clientY - current.y)
}
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
const card = event.currentTarget
const current = drag.current
if (current === null || current.pointerId !== event.pointerId) return
drag.current = null
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
if (decides && moved && !deciding.current) track(card, current, event)
if (current.transform === null) return
if (deciding.current) unfollow(card)
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
else if (prefersReducedMotion()) unfollow(card)
else springing.current = snapBack(card, current.transform)
}
return {
onPointerDown: (event) => {
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
stopSpring()
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
},
onPointerMove: (event) => {
const current = drag.current
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
track(event.currentTarget, current, event)
},
onPointerUp: (event) => release(event, true),
onPointerCancel: (event) => release(event, false),
// After a release the drag is already over, so this only ends a drag
// whose capture was lost before it.
onLostPointerCapture: (event) => release(event, false),
}
}, [decide, stopSpring])
// Leaving the page stops an exit before its decision is sent.
useEffect(() => () => exit.current?.cancel(), [])
const topId = top?.id
// A drag belongs to the card it started on, which a new top card ends.
useLayoutEffect(() => {
drag.current = null
}, [topId])
// Once the deck moves past the card a decision was sent for, the cards
// behind it move up a depth.
const topId = top?.id
useLayoutEffect(() => {
if (leaving.current === null || leaving.current === topId) return
leaving.current = null
exit.current = null
if (topId === undefined || prefersReducedMotion()) return
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
}, [topId])
@@ -196,17 +316,29 @@ export function DiscoverPage() {
}, [decide])
// The batch stores its Side quests as it goes, so the deck is read again
// whenever it reports more, and once it ends.
// whenever it reports more, and once it ends. A read already out may be a
// decision's, which must not be cut short: the batch's read waits for it,
// then goes out after it, as it may predate what the batch reported.
const { stored } = batch
useEffect(() => {
if (job.status === undefined) return
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0
void queryClient
.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false })
.then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined))
}, [queryClient, job.status, stored])
// A failed decision can be retried only while the deck's newest read
// still has its Side quest pending: anything else decided it already.
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
const retryable = pending !== undefined && pending !== null
let body
if (deck.isError) {
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
} else if (deck.isPending || (cards!.length === 0 && (finding || decision.isPending))) {
// An empty deck is not caught up while a batch finds more Side quests,
// or while the last decision may yet start one or come back refused.
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
@@ -224,12 +356,12 @@ export function DiscoverPage() {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: getMore }}>
New side quests arrive Sunday.
</EmptyState>
</>
@@ -238,8 +370,8 @@ export function DiscoverPage() {
body = (
<>
<div ref={deckRef} className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} />
{cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
))}
</div>
<div className={styles.actions}>
@@ -250,11 +382,6 @@ export function DiscoverPage() {
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
@@ -272,6 +399,15 @@ export function DiscoverPage() {
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
{/* A failed decision is shown whatever the deck has come to since. */}
{decision.isError &&
(retryable ? (
<ErrorBanner onRetry={() => submit(decision.variables)}>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : pending === undefined ? (
<ErrorBanner>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : (
<ErrorBanner>That side quest has been decided already. {decision.error.message}</ErrorBanner>
))}
</div>
)
}
+100 -23
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
@@ -8,14 +8,18 @@ export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's.
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's;
// DECIDE_KEY the decisions'.
export const DECK_KEY = ['suggestions', 'deck'] as const
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
const DECIDE_KEY = ['suggestions', 'decide'] as const
// SideQuest is a pending suggestion as the deck shows it. photo_id is the
// stored photo's id, from photo_url; reason is the "Why this?".
// SideQuest is a pending suggestion as the deck shows it. dinner_id is the
// dinner Try it moves to the Quest log; photo_id is the stored photo's id,
// from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
dinner_id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
@@ -61,6 +65,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.dinner_id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
@@ -68,6 +73,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
}
return {
id: body.id,
dinner_id: body.dinner_id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
@@ -126,31 +132,102 @@ export function useStartBatch() {
})
}
// stillPending says whether the deck's newest read has the Side quest id
// pending, after a failed decision on it: the Side quest when it does, null
// when it does not, and undefined when that read failed, which says neither.
// deck is the deck query's state, from the cache or useDeck.
export function stillPending(deck: { status: string; data: SideQuest[] | undefined } | undefined, id: string): SideQuest | null | undefined {
if (deck?.status !== 'success') return undefined
return deck.data!.find((q) => q.id === id) ?? null
}
// useDeciding is the ids of the Side quests whose decisions are pending:
// sent and not yet settled. The deck shown leaves them out, whatever a read
// of the deck has said meanwhile.
export function useDeciding(): Set<string> {
const ids = useMutationState<string>({
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
})
return new Set(ids)
}
// 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. The answer must be that suggestion, decided that way.
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
// decided on, whether or not a read of the deck has since taken it away.
// The answer must be that suggestion, decided that way; the mutation's data
// is the id of the replenish job the decision started, or null.
//
// The decision is applied at once: the Side quest leaves the deck shown
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
// Quest log unless it is there already. Nothing else is worked out here:
// the server is the record of both. However the decision ends, saved or
// failed, the deck and the Quest log are read again, and the decision is
// pending until those reads land, so the next one (which DiscoverPage locks
// out until then) cannot start under them. A read under way when the
// decision ends is replaced by the read made after it, and DiscoverPage's
// batch reads wait for that one rather than replace it.
// A failed decision waits for the deck read it makes itself. Whether its
// Side quest is still pending is then the deck query's to say, from its
// newest successful read: once the reads have landed, a Side quest the deck
// still has goes back on top of it.
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'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(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`)
// send POSTs the decision and checks the answer.
async function send(id: string, choice: Choice): Promise<string | null> {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(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`)
}
if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') {
throw new Error(`POST ${path}: response has no replenish_job_id`)
}
return body.replenish_job_id
}
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
mutationKey: DECIDE_KEY,
mutationFn: async ({ quest, choice }) => {
try {
return await send(quest.id, choice)
} catch (error) {
// The deck is read again as the decision's own fetch, replacing any
// read already out. A failed read is the deck query's error, shown
// with the deck; the decision's own error is the one thrown.
await queryClient
.getQueryCache()
.build<SideQuest[]>(queryClient, { queryKey: DECK_KEY })
.fetch(undefined, { cancelRefetch: true })
.catch(() => undefined)
throw error
}
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
onMutate: ({ quest, choice }) => {
if (choice !== 'try') return
// A stale deck can offer a Side quest whose dinner the Quest log has.
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners : dinners.concat({ id: quest.dinner_id, name: quest.name }),
)
},
onSettled: async (_replenishJobId, error, { quest }) => {
if (error === null) {
await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })])
return
}
// A failed decision has read the deck already; any read since is newer.
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
const pending = stillPending(queryClient.getQueryState<SideQuest[]>(DECK_KEY), quest.id)
if (pending === null || pending === undefined) return
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => [pending, ...deck!.filter((q) => q.id !== pending.id)])
},
})
}
+48 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
import { bump, fadeOut, isAbort, pass, promote, tryIt } from './motion.ts'
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
let animations: FakeAnimation[]
@@ -15,10 +15,11 @@ afterEach(() => {
// card builds a deck card with the parts DeckCard marks.
function card() {
const article = document.createElement('article')
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="shade"></span>'
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
return {
article,
stamp: article.querySelector('[data-part="try-stamp"]')!,
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
shade: article.querySelector('[data-part="shade"]')!,
}
}
@@ -169,8 +170,52 @@ describe('motion', () => {
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
})
it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
})
it.each([
[50, '0.5', '0'],
[-50, '0', '0.5'],
[160, '1', '0'],
[-160, '0', '1'],
[0, '0', '0'],
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
const { article, stamp, passStamp } = card()
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
expect(article.style.transform).toBe(dragTransform(dx, 0))
expect(stamp.style.opacity).toBe(tryOpacity)
expect(passStamp.style.opacity).toBe(passOpacity)
expect(animations).toEqual([])
})
it('unfollows at once, back to the stylesheet', () => {
const { article, stamp, passStamp } = card()
follow(article, -80, 12)
unfollow(article)
expect(article.getAttribute('style')).toBe('')
expect(stamp.getAttribute('style')).toBe('')
expect(passStamp.getAttribute('style')).toBe('')
expect(animations).toEqual([])
})
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
const { article, stamp, passStamp } = card()
const from = follow(article, 70, 30)
snapBack(article, from)
expect(article.style.transform).toBe('')
expect(stamp.style.opacity).toBe('')
expect(passStamp.style.opacity).toBe('')
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(article)
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
})
it('fails fast on a card without its parts', () => {
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
})
})
+39 -3
View File
@@ -1,7 +1,7 @@
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
// §5 (deck advance) and §6 (reduced motion), with the Web Animations API.
// The exits take the card's starting transform so the swipe gesture can
// throw a dragged card from where it was let go.
// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
// Animations API. The exits take the card's starting transform so the swipe
// gesture can throw a dragged card from where it was let go.
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
const THROW = 'cubic-bezier(.55,0,.8,.4)'
@@ -93,6 +93,38 @@ export function pass(card: HTMLElement, from: string = REST): Exit {
return exit.done(finished)
}
// dragTransform is the top card's transform dragged dx, dy from where the
// pointer went down.
export function dragTransform(dx: number, dy: number): string {
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
}
// follow moves a dragged card to its drag transform and shows each stamp as
// far as the drag has gone toward its side, fully at 100 px. It returns the
// transform, for the exit or snap-back the release starts.
export function follow(card: HTMLElement, dx: number, dy: number): string {
const transform = dragTransform(dx, dy)
card.style.transform = transform
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
return transform
}
// unfollow puts a dragged card and its stamps back where the stylesheet
// places them, at once.
export function unfollow(card: HTMLElement) {
card.style.removeProperty('transform')
part(card, 'try-stamp').style.removeProperty('opacity')
part(card, 'pass-stamp').style.removeProperty('opacity')
}
// snapBack springs a card let go short of a decision back to rest from its
// drag transform, and hides its stamps.
export function snapBack(card: HTMLElement, from: string): Animation {
unfollow(card)
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
}
// fadeOut is both exits under prefers-reduced-motion: reduce.
export function fadeOut(card: HTMLElement): Exit {
const exit = recorder()
@@ -110,6 +142,10 @@ export function promote(cards: HTMLElement[]): Animation[] {
)
}
function clamp(value: number): number {
return Math.min(1, Math.max(0, value))
}
// part is the card's element marked data-part="name", which DeckCard renders.
function part(card: HTMLElement, name: string): HTMLElement {
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
+6
View File
@@ -24,6 +24,12 @@ describe('ErrorBanner', () => {
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
})
it('has no button without onRetry', () => {
render(<ErrorBanner>That was done already.</ErrorBanner>)
expect(screen.getByRole('alert')).toHaveTextContent('That was done already.')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is a neutral-100 card with grape-700 text', () => {
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
+5 -3
View File
@@ -6,16 +6,18 @@ import styles from './ErrorBanner.module.css'
export type ErrorBannerProps = {
// What went wrong, in the caller's words.
children: ReactNode
onRetry: () => void
// What Retry does; without it there is nothing to retry and no button.
onRetry?: () => void
retryLabel?: string
}
// Muted neutral-100 card with grape-700 text and a Retry button.
// Muted neutral-100 card with grape-700 text and, given onRetry, a Retry
// button.
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
return (
<Card tone="muted" role="alert" className={styles.banner}>
<p className={styles.message}>{children}</p>
<Button onClick={onRetry}>{retryLabel}</Button>
{onRetry !== undefined && <Button onClick={onRetry}>{retryLabel}</Button>}
</Card>
)
}