Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd607f125 | ||
|
|
701021d327 | ||
|
|
8b18399710 | ||
|
|
f26cfd5129 | ||
|
|
3319434bd1 | ||
|
|
1c59e5d9d7 | ||
|
|
0ea1c218ea | ||
|
|
6c105416a9 | ||
|
|
74611da62b | ||
|
|
9088c313cc |
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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)])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}"]`)
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user