Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #108.
This commit is contained in:
@@ -12,11 +12,14 @@ import {
|
||||
BATCH_JOB_KIND,
|
||||
DECK_KEY,
|
||||
parseBatchResult,
|
||||
stillPending,
|
||||
useDeciding,
|
||||
useDeck,
|
||||
useDecide,
|
||||
useQuestLog,
|
||||
useStartBatch,
|
||||
type Choice,
|
||||
type SideQuest,
|
||||
} from './api.ts'
|
||||
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
||||
import {
|
||||
@@ -88,32 +91,44 @@ 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, the ← / → keys
|
||||
// or a swipe of the top card throws it 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.
|
||||
// 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
|
||||
@@ -122,8 +137,8 @@ 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)
|
||||
@@ -143,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)
|
||||
},
|
||||
@@ -162,7 +182,7 @@ export function DiscoverPage() {
|
||||
[mutateDecision],
|
||||
)
|
||||
const submit = useCallback(
|
||||
(variables: { id: string; choice: Choice }) => {
|
||||
(variables: { quest: SideQuest; choice: Choice }) => {
|
||||
if (lock()) send(variables)
|
||||
},
|
||||
[lock, send],
|
||||
@@ -207,7 +227,7 @@ export function DiscoverPage() {
|
||||
() => {
|
||||
// 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
|
||||
@@ -278,7 +298,6 @@ export function DiscoverPage() {
|
||||
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])
|
||||
@@ -297,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}>
|
||||
@@ -325,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>
|
||||
</>
|
||||
@@ -339,7 +370,7 @@ export function DiscoverPage() {
|
||||
body = (
|
||||
<>
|
||||
<div ref={deckRef} className={styles.deck}>
|
||||
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||
{cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||
))}
|
||||
</div>
|
||||
@@ -351,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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -373,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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user