import { useQueryClient } from '@tanstack/react-query' import { Heart, X } from 'lucide-react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react' import { useJob } from '../api/index.ts' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { BATCH_JOB_KIND, DECK_KEY, parseBatchResult, stillPending, useDeciding, useDeck, useDecide, useQuestLog, useStartBatch, type Choice, type QuestLogDinner, type SideQuest, } from './api.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 { QuestLogSheet } from './QuestLogSheet.tsx' 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 { return ( target instanceof HTMLElement && (target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) ) } // QuestLogPill is the "Quest log · N" pill, which opens the Quest log // sheet. ref is the visible pill, which Try it bumps. function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref }) { const [open, setOpen] = useState(false) return ( <> setOpen(false)} /> ) } // 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, 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?.suggestions.filter((quest) => !inFlight.has(quest.id)) const questLog = useQuestLog() const decision = useDecide() const startBatch = useStartBatch() // The job of the batch last known to be finding Side quests, or to have // failed to: the one Get more now started or joined, the replenish a // decision started or joined, or the deck's active or failed batch, which // may have been started elsewhere or before the page opened. const [batchJobId, setBatchJobId] = useState(null) const job = useJob(BATCH_JOB_KIND, batchJobId) // A read of the deck reporting a new active or failed batch follows its // job from that render on. One reporting neither leaves the last job // followed, so its end stays shown — unless that job is known to have // failed, by its poll or the deck's last read: the deck reports the newest // batch's failure, so a later batch has superseded it, and it goes. const failedJobId = deck.data?.failedBatch?.job_id ?? null const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId const [seenRead, setSeenRead] = useState({ at: 0, jobId: null as string | null, failedJobId: null as string | null }) if (deck.dataUpdatedAt !== seenRead.at) { setSeenRead({ at: deck.dataUpdatedAt, jobId: deckJobId, failedJobId }) if (deckJobId !== null && deckJobId !== seenRead.jobId) setBatchJobId(deckJobId) else if (deckJobId === null && batchJobId !== null && (job.status === 'failed' || batchJobId === seenRead.failedJobId)) setBatchJobId(null) } const top = cards?.[0] const [busy, setBusy] = useState(false) // The deck's failed batch is not finding anything, even before its job's // first poll says why it failed. const jobRunning = batchJobId !== null && batchJobId !== failedJobId && 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/${batchJobId}`, job.result).suggestion_ids.length, error: null } } catch (error) { return { stored: 0, error: error as Error } } }, [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 const deckRef = useRef(null) const passRef = useRef(null) const tryRef = useRef(null) const questLogRef = useRef(null) // 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(null) const leaving = useRef(null) const drag = useRef(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(null) const stopSpring = useCallback(() => { springing.current?.cancel() springing.current = null }, []) const { mutate: mutateDecision } = decision const deciding = useRef(false) const lock = useCallback(() => { if (deciding.current) return false deciding.current = true setBusy(true) return true }, []) // 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 or // joined a replenish batch follows its job. const send = useCallback( (variables: { quest: SideQuest; choice: Choice }) => { leaving.current = variables.quest.id mutateDecision(variables, { onSuccess: (replenishJobId) => { if (replenishJobId !== null) setBatchJobId(replenishJobId) }, onError: () => { exit.current?.cancel() leaving.current = null }, onSettled: () => { exit.current = null deciding.current = false setBusy(false) }, }) }, [mutateDecision], ) const submit = useCallback( (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, 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 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({ quest: top, choice }) }, (error: unknown) => { if (!isAbort(error)) throw error }, ) }, [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(() => { function track(card: HTMLElement, current: Drag, event: PointerEvent) { current.dx = event.clientX - current.x current.transform = follow(card, current.dx, event.clientY - current.y) } function release(event: PointerEvent, 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. useLayoutEffect(() => { if (leaving.current === null || leaving.current === topId) return leaving.current = null if (topId === undefined || prefersReducedMotion()) return promote([...deckRef.current!.querySelectorAll(':scope > article')]) }, [topId]) useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return if (isEditable(event.target)) return // A modal over the page (the Quest log sheet or its confirmation) makes // the deck inert: its keys are the modal's, not the deck's. if (deckRef.current === null || deckRef.current.closest('[inert]') !== null) return if (event.key === 'ArrowLeft') decide('pass') else if (event.key === 'ArrowRight') decide('try') else return event.preventDefault() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [decide]) // The batch stores its Side quests as it goes, so the deck is read again // 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 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 // What went wrong asking for or finding more Side quests, shown whatever // the deck has: a replenish can fail while cards remain. While Get more now // is asking again, the last batch's failure is not the news. const batchFailure = startBatch.isPending ? null : startBatch.isError ? ( Couldn't ask for more side quests. {startBatch.error.message} ) : batchError === null ? null : jobRunning ? ( Couldn't check on new side quests. {batchError.message} ) : ( Couldn't find new side quests. {batchError.message} ) let body if (deck.isError) { body = void deck.refetch()}>Couldn't load your side quests. {deck.error.message} } 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 = (
{DECK_DEPTH.map((depth) => ( ))}
{finding &&

Finding new side quests…

}
) } else if (top === undefined) { body = ( New side quests arrive Sunday. ) } else { const shown = cards!.slice(0, DECK_DEPTH.length) body = ( <> {/* The deck keeps all three depths: a short deck fills those it has no card for with skeleton cards while a batch finds more Side quests, and with blank cards, the stack's edge, while none is coming. */}
{shown.map((quest, depth) => ( ))} {DECK_DEPTH.slice(shown.length).map((depth) => finding ? ( ) : (
decide('pass')}> decide('try')}>
{finding &&

Finding new side quests…

} ) } return (

Discover

Side quests

{questLog.isSuccess && }
{questLog.isError && ( void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message} )} {body} {batchFailure} {/* A failed decision is shown whatever the deck has come to since. */} {decision.isError && (retryable ? ( submit(decision.variables)}>Couldn't save that choice. {decision.error.message} ) : pending === undefined ? ( Couldn't save that choice. {decision.error.message} ) : ( That side quest has been decided already. {decision.error.message} ))}
) }