import { useQueryClient } from '@tanstack/react-query' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import { useJob } from '../api/index.ts' 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 { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { Button, type ButtonVariant } from '../ui/Button.tsx' 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 { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, useSwapDay, type DraftResult, type Plan, type PlanDay, type PlanDinner, type PlanStatus, type Swap, } from './api.ts' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx' import { SwapSheet } from './SwapSheet.tsx' import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart as weekOf, } from './week.ts' import styles from './PlanPage.module.css' // statusTags is the tag beside the week's title for each plan status. const statusTags: Record = { draft: { label: 'Draft', tone: 'grape' }, synced: { label: 'Synced', tone: 'teal' }, changed: { label: 'Changes not pushed', tone: 'grape' }, } // draftFailures says why a draft_week job failed, by its failure code. Any // other code is shown as it is. const draftFailures: Record = { not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.', ai_failed: "The AI assistant couldn't draft it.", confirm_required: 'The plan was pushed to Calendar while it was drafting.', plan_changed: 'The plan was edited while it was drafting.', pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.', internal_error: 'Something went wrong on the server.', } // PUSH_HINT is why Push to Calendar is unavailable. // TODO(Feature 16): connect Push to Calendar once Google Calendar is. const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' // 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 = { draft: 'Not pushed to Google Calendar yet', synced: 'Synced to Google Calendar', changed: 'Changes not pushed to Google Calendar', } // 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. export function duration(minutes: number): string { const hours = Math.floor(minutes / 60) const rest = minutes % 60 if (hours === 0) return `${rest}\u00a0min` return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min` } // cooking is a card's "method · duration" line, "Crock pot · 6 hr", with // whichever of the two is known, or null when neither is. export function cooking(dinner: PlanDinner): string | null { const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter( (part): part is string => typeof part === 'string', ) return parts.length === 0 ? null : parts.join(' · ') } // ago is how long before now the instant at was: "just now", "2 min ago", // "3 hr ago", "2 days ago". export function ago(at: Date, now: Date): string { const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000) if (minutes < 1) return 'just now' if (minutes < 60) return `${minutes} min ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours} hr ago` const days = Math.floor(hours / 24) return days === 1 ? '1 day ago' : `${days} days ago` } // pushedToCalendar says whether drafting over a plan of this status needs // confirm: it was pushed to Calendar. function pushedToCalendar(status: PlanStatus): boolean { return status !== 'draft' } // DayTile is one day of the Draft board: a Regular on the surface, a // Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's // start-by time in the household's zone. Tapping it opens the swap sheet; // while that is open the tile is swapping: on the page background, ringed in // grape, its name muted. function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { const kicker = weekdayKicker(day.date) const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ') return (
  • ) } // DayColumn is one day of the plan manager's 7-column grid: its day label // over a card with the dinner's washed photo, name, method and time, a // crock-pot dinner's start-by time in the household's zone, and its Regular // or Side quest tag, a Side quest's card in teal. The swap icon opens the swap // sheet; while that is open the card is swapping, ringed in grape. A dinner // dragged over the day drops on it: the card is swapping, covered by a // dashed grape "Drop to swap" saying which dinner it replaces. function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { const { dinner } = day const time = cooking(dinner) const target = useDropTarget(day.date) !== null const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ') return (
  • {/* The card's name already says what the photo shows. */}

    {dinner.name}

    {time !== null &&

    {time}

    } {day.start_by !== null && (

    Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}

    )}
    {day.is_new ? 'Side quest' : 'Regular'}
    {target && (

    Drop to swap

    Replaces {dinner.name}

    )}
  • ) } // 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 }) { const wide = layout !== 'mobile' return (
    {Array.from({ length: 7 }, (_, i) => ( ))}
    {children !== undefined &&

    {children}

    }
    ) } // WeekBoard is the Draft board of the week starting on weekStart. Drafting // it — the empty state's "Draft this week" or the "+ Rebuild week" tile — // starts the draft_week job and shows skeleton tiles until the job ends and // 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 and the // unavailable Shopping list and Push to Calendar are actions in the header, // over the plan's sync status and the 7-column grid. function WeekBoard({ weekStart, timezone, layout, onWeek, }: { weekStart: string timezone: string layout: Breakpoint onWeek: (weekStart: string) => void }) { const queryClient = useQueryClient() const plan = usePlan(weekStart) const startDraft = useStartDraft() const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) // The draft's result, once the job has succeeded, or why it cannot be read. const result = useMemo((): { draft: DraftResult | null; error: Error | null } => { if (job.status !== 'succeeded') return { draft: null, error: null } try { return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null } } catch (error) { return { draft: null, error: error as Error } } }, [job.status, job.result, jobId, weekStart]) // Once the job ends the plan is read again: a succeeded draft is followed // until that read lands; a failed one stays shown, and may have met a // plan changed meanwhile. The Quest log is read again too, as after a // swap: the draft may have planned some of it. const [readJobId, setReadJobId] = useState(null) const ended = job.status === 'succeeded' || job.status === 'failed' useEffect(() => { if (!ended) return let active = true void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { if (active) setReadJobId(jobId) }) return () => { active = false } }, [queryClient, ended, jobId, weekStart]) // The plan read shows once it is the one drafted, read from the server; no // plan, another one, or a copy cached before the draft (a redraft keeps the // plan's id) is an error rather than an empty or stale board. Its Retry // reads the plan again, keeping the job: a later read from the server that // is the draft still lands it. const { draft } = result const read = draft !== null && readJobId === jobId && plan.isSuccess const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached const unreadError = read && !landed ? new Error( plan.data === null ? `The week has no plan, not draft ${draft.plan_id}.` : plan.data.id !== draft.plan_id ? `The week's plan is not draft ${draft.plan_id}.` : `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`, ) : null const resultError = result.error ?? unreadError useEffect(() => { if (landed) setJobId(null) }, [landed]) const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) const { mutate } = startDraft function start(confirm: boolean) { setConfirming(false) // A draft asked for again leaves the last one's failure, and a failed // swap's, behind. setJobId(null) swap.dismiss() mutate( { weekStart, confirm }, { onSuccess: setJobId, // A plan pushed to Calendar meanwhile needs confirm: read it again. onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }), }, ) } // rebuild drafts the week, asking first over a plan pushed to Calendar. // It waits for a swap of the week still pending, which may yet fail: the // Rebuild week tile is disabled meanwhile. function rebuild() { if (swap.pending) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } const failure = swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} ) : startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} ) : result.error !== null ? ( Couldn't read the draft. {result.error.message} ) : unreadError !== null ? ( void plan.refetch()}>Couldn't read the draft. {unreadError.message} ) : job.error === null ? null : job.status === 'failed' ? ( Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message} ) : ( Couldn't check on the draft. {job.error.message} ) // swapIn sends the swap chosen in the sheet and closes it. function swapIn(chosen: Swap) { setSwappingDate(null) swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, // nor dropped, while a swap of the week is pending. function dropIn(chosen: Swap) { swap.dismiss() swapIn(chosen) } // openSwap opens the swap sheet on a day. One swap of the week is sent at // a time, even across leaving the week and coming back: the plan the // server answers it with must not overwrite another still pending. function openSwap(date: string) { if (swap.pending) return swap.dismiss() setSwappingDate(date) } let body if (plan.isError) { body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} } else if (drafting) { body = ( Drafting your week… ) } else if (resultError !== null) { // An unread draft shows no board: the plan read is not the draft. body = null } else if (plan.isPending) { body = } else if (plan.data === null) { body = ( I'll draft seven dinners from your Regulars and Quest log. ) } else { const { ai_note, days } = plan.data const swappingDay = days.find((day) => day.date === swappingDate) const sheet = swappingDay !== undefined && ( day.dinner.id))} onSwap={swapIn} onClose={() => setSwappingDate(null)} /> ) body = layout !== 'mobile' ? ( <>
      {days.map((day) => ( openSwap(day.date)} /> ))}
    {sheet} ) : ( <> {ai_note !== null && (

    {ai_note}

    )}
      {days.map((day) => ( openSwap(day.date)} /> ))}
    Looks good — push to Calendar {sheet} ) } const tag = plan.data ? statusTags[plan.data.status] : null const header = layout !== 'mobile' ? (

    {label.week} · Plan

    {label.dates}

    onWeek(addDays(weekStart, -7))}> {layout === 'desktop' && ( )} onWeek(addDays(weekStart, 7))}>
    {plan.data ? : }
    Shopping list Push to Calendar
    ) : (

    {label.week}

    {label.dates}

    {tag !== null && {tag.label}}
    onWeek(addDays(weekStart, -7))}> onWeek(addDays(weekStart, 7))}>
    ) return ( <> {header} {body} {failure} setConfirming(false)} onConfirm={() => start(true)} > This week is already pushed to Calendar. The AI assistant drafts a new plan in its place. ) } // SyncStatus is the plan manager's status line: whether the plan is on // Google Calendar and, once synced, how long ago it was pushed, kept current // minute by minute. function SyncStatus({ plan }: { plan: Plan }) { const { status, calendar_synced_at: syncedAt } = plan const [now, setNow] = useState(() => new Date()) useEffect(() => { const timer = setInterval(() => setNow(new Date()), 60_000) return () => clearInterval(timer) }, []) return (

    {status === 'synced' && } {syncStatuses[status]} {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}

    ) } // Unavailable is an action with nothing behind it yet (Push to Calendar, 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({ hint, variant = 'primary', below = false, className, children, }: { hint: string variant?: ButtonVariant below?: boolean className?: string children: ReactNode }) { const hintId = useId() return (
    {hint}
    ) } // PlanPage is the Draft board on the week chosen with the previous / next // week buttons: mock 1f on mobile; from tablet up the plan manager, with the // Quest log as a strip under the grid on tablet (2c) and the Quest log and // Regulars in a right panel on desktop (2d). It opens on next week once this // week's plan_draft time has come in the household's zone, otherwise on this // week. The board keeps its place in the tree across layouts, so crossing a // breakpoint keeps a draft being followed. From tablet up a dinner is // dragged from the strip or the panel onto a day of the grid to swap it in. export function PlanPage() { const layout = useBreakpoint() const settings = useHouseholdSettings() const [week, setWeek] = useState(null) if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) let content if (settings.isError) { content = ( void settings.refetch()}>Couldn't load the household settings. {settings.error.message} ) } else if (week === null || !settings.data) { content = } else { content = } return (
    {content} {layout === 'tablet' && }
    {layout === 'desktop' && } {layout !== 'mobile' && }
    ) }