Feature 17: Shopping List and Supply Archive (#140)
CI / ci (pull_request) Successful in 5m23s
CI / ci (push) Successful in 5m45s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #140.
This commit is contained in:
2026-10-08 15:16:51 -04:00
committed by approver_agent
parent 7003ff92ec
commit cb451baed4
67 changed files with 9252 additions and 1666 deletions
+89 -17
View File
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
@@ -29,6 +31,7 @@ import {
type PlanDay,
type PlanDinner,
type PlanStatus,
type PushedSupplyRun,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -79,10 +82,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
},
}
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet',
@@ -90,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar',
}
// supplyRunOutcomes says what became of the week's Supply run after a push
// that made it or left it as it was.
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
generated: "Made this week's Supply run from the plan.",
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
@@ -195,6 +201,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
)
}
// ShoppingList opens the week's Supply run: an outline action in the plan
// manager's header, a grape link under the Draft board on mobile. Once the
// week has a Supply run it says how many items are still to buy, "Shopping
// list · 28"; until then, while that is read, or when reading it failed, it
// says no count, and the Supply run screen offers to make the list or shows
// the failure.
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={supplyRunScreen(weekStart)} className={className}>
<Icon icon={ShoppingCart} size="inline" />
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
</Link>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
@@ -217,13 +239,16 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
//
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
// Calendar and the unavailable Shopping list are actions in the header,
// over the plan's sync status and the 7-column grid.
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Rebuild week and Push to Calendar are actions in the header, over the
// plan's sync status and the 7-column grid. On mobile the Shopping list
// link sits under the push button.
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it.
// Calendar settings when only they can fix it. What became of the week's
// Supply run is said too: made, left as it was in the Supply archive, or
// not made, with the way to make it.
function WeekBoard({
weekStart,
timezone,
@@ -243,6 +268,9 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -312,6 +340,7 @@ function WeekBoard({
if (!canPush) return
swap.dismiss()
push.reset()
generate.reset()
push.mutate()
}
@@ -323,6 +352,7 @@ function WeekBoard({
setJobId(null)
swap.dismiss()
push.reset()
generate.reset()
mutate(
{ weekStart, confirm },
{
@@ -341,8 +371,33 @@ function WeekBoard({
else start(false)
}
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
// makeShoppingList makes the week's Supply run again after a push could
// not make it. Once made, here or on the Supply run screen, the push is
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
// generate.error reports.
function makeShoppingList() {
generate.mutate()
}
// A push's Supply run made again on the Plan stands for the push's, which
// is settled.
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
supplyRun.error.code === 'plan_changed' ? (
<ErrorBanner onRetry={retryPush}>
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
Pushed to Calendar, but this week's Supply run couldn't be made.
{generate.isPending
? ' Making the shopping list…'
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
</ErrorBanner>
)
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -360,11 +415,27 @@ function WeekBoard({
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
<p role="status" className={styles.pushed}>
{supplyRunOutcomes[supplyRun.status]}
</p>
)
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
// successful read only: a failed refetch keeps the earlier Supply run in
// run.data, and its count is then no longer current.
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
const supplyRunFailure = run.isError && (
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
generate.reset()
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -462,6 +533,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet}
</>
)
@@ -493,10 +566,7 @@ function WeekBoard({
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
{shoppingList}
<Button
variant="outline"
className={styles.action}
@@ -537,6 +607,8 @@ function WeekBoard({
{header}
{body}
{failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
title="Rebuild this week?"
@@ -600,10 +672,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
)
}
// Unavailable is an action with nothing behind it yet (the shopping list):
// it stays focusable, so a tap or focus shows the tooltip saying why, above
// it or, at the top of the page, below.
function Unavailable({
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
below = false,