2026-10-07 16:53:23 -04:00
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays , Check , ChevronLeft , ChevronRight , Plus , RefreshCw , ShoppingCart } from 'lucide-react'
2026-10-09 18:04:05 -04:00
import { useEffect , useMemo , useState } from 'react'
2026-10-10 20:07:53 -04:00
import { Link , useSearchParams } from 'react-router'
2026-10-08 03:13:21 -04:00
import { ApiError , useJob } from '../api/index.ts'
2026-10-07 16:53:23 -04:00
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'
2026-10-10 20:07:53 -04:00
import { supplyRunScreen , useGenerateSupplyRun , useSupplyRun , WEEK_PARAM } from '../shopping/api.ts'
2026-10-10 23:35:24 -04:00
import { useBreakpoint , useOrientation , type Breakpoint } from '../shell/useBreakpoint.ts'
2026-10-09 18:04:05 -04:00
import { Button } from '../ui/Button.tsx'
2026-10-08 15:16:51 -04:00
import buttonStyles from '../ui/Button.module.css'
2026-10-07 16:53:23 -04:00
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 ,
2026-10-09 14:01:14 -04:00
useWeekSwap ,
2026-10-07 16:53:23 -04:00
type DraftResult ,
type Plan ,
type PlanDay ,
type PlanDinner ,
type PlanStatus ,
2026-10-08 15:16:51 -04:00
type PushedSupplyRun ,
2026-10-07 16:53:23 -04:00
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 ,
2026-10-10 20:07:53 -04:00
isMonday ,
2026-10-07 16:53:23 -04:00
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 < PlanStatus , { label : string ; tone : 'grape' | 'teal' }> = {
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 < string , string > = {
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.' ,
}
2026-10-08 03:13:21 -04:00
// CALENDAR_SETTINGS is the Google Calendar settings screen.
const CALENDAR_SETTINGS = '/settings/calendar'
// settingsFailures says why a push failed, by its API error code, for the
// failures only the Google Calendar settings can fix, and what to do there.
const settingsFailures : Record < string , { reason : string ; fix : string }> = {
google_reconnect_required : { reason : 'Google access was revoked or expired.' , fix : 'Reconnect Google Calendar' },
not_connected : { reason : 'Google Calendar is not connected.' , fix : 'Connect Google Calendar' },
no_calendar : { reason : 'No calendar is chosen for dinners yet.' , fix : 'Choose a calendar in Settings' },
calendar_unavailable : {
reason : 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.' ,
fix : 'Choose another calendar' ,
},
}
2026-10-07 16:53:23 -04:00
// 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' ,
synced : 'Synced to Google Calendar' ,
changed : 'Changes not pushed to Google Calendar' ,
}
2026-10-08 15:16:51 -04:00
// 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." ,
}
2026-10-07 16:53:23 -04:00
// 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`
}
2026-10-11 01:21:55 -04:00
// alreadyPlanned says whether a swap was refused because its dinner is
// already on another day of the week: sending it again cannot succeed.
function alreadyPlanned ( error : Error ) : boolean {
return error instanceof ApiError && error . code === 'dinner_already_planned'
}
2026-10-07 16:53:23 -04:00
// 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 (
< li className = { classes }>
< button type = "button" className = { styles . tileButton } onClick = { onSwap }>
< span className = { styles . dayKicker }>{ swapping ? ` ${ kicker } · SWAPPING…` : day . is_new ? ` ${ kicker } · NEW` : kicker }</ span >
< span >
< span className = { styles . dinner }>{ day . dinner . name }</ span >
{ day . start_by !== null && (
< span className = { styles . startBy }> Start by { formatClockTime ( localNow ( timezone , new Date ( day . start_by )). time )}</ span >
)}
</ span >
</ button >
</ li >
)
}
// 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 (
< li className = { styles . column } aria-label = { weekdayName ( day . date )} { ... { [ DROP_DATE ]: day.date }}>
< p className = { styles . dayLabel } aria-hidden = "true" >
< span className = { styles . dayName }>{ weekdayKicker ( day . date )}</ span >
< span className = { styles . dayNumber }>{ dayOfMonth ( day . date )}</ span >
</ p >
< div className = { classes }>
{ /* The card's name already says what the photo shows. */ }
< span aria-hidden = "true" >
< Photo id = { dinner . photo_id } name = { dinner . name } radius = "18px" sizes = "160px" className = { styles . cardPhoto } />
</ span >
< p className = { styles . cardName }>{ dinner . name }</ p >
{ time !== null && < p className = { styles . cardMinutes }>{ time }</ p >}
{ day . start_by !== null && (
< p className = { styles . cardStartBy }> Start by { formatClockTime ( localNow ( timezone , new Date ( day . start_by )). time )}</ p >
)}
< div className = { styles . cardFooter }>
< Tag tone = { day . is_new ? 'grape' : 'neutral' } className = { styles . cardTag }>{ day . is_new ? 'Side quest' : 'Regular' }</ Tag >
< IconButton label = { `Swap ${ weekdayName ( day . date ) } ` } className = { styles . swapButton } onClick = { onSwap }>
< Icon icon = { RefreshCw } size = "inline" />
</ IconButton >
</ div >
{ target && (
< div className = { styles . dropTarget }>
< p className = { styles . dropTitle }> Drop to swap </ p >
< p className = { styles . dropReplaces }> Replaces { dinner . name }</ p >
</ div >
)}
</ div >
</ li >
)
}
2026-10-08 15:16:51 -04:00
// 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 >
)
}
2026-10-07 16:53:23 -04:00
// 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 (
< section aria-busy = "true" aria-label = { label } className = { styles . loading }>
< div className = { wide ? ` ${ styles . week } ${ styles [ layout ] } ` : styles . grid }>
{ Array . from ({ length : 7 }, ( _ , i ) => (
< Skeleton key = { i } data-testid = "skeleton-tile" className = { wide ? styles.skeletonCard : styles.skeletonTile } />
))}
</ div >
{ children !== undefined && < p className = { styles . drafting }>{ children }</ p >}
</ section >
)
}
// 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.
//
2026-10-08 15:16:51 -04:00
// 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.
2026-10-08 03:13:21 -04:00
//
// 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
2026-10-08 15:16:51 -04:00
// 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.
2026-10-07 16:53:23 -04:00
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 < string | null >( null )
const job = useJob < unknown >( DRAFT_JOB_KIND , jobId )
const [ confirming , setConfirming ] = useState ( false )
2026-10-09 14:01:14 -04:00
const { swap , push , refused , swapIn : sendSwap } = useWeekSwap ( weekStart )
2026-10-08 15:16:51 -04:00
const run = useSupplyRun ( weekStart )
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun ( weekStart )
2026-10-08 03:13:21 -04:00
const pushing = push . pending
2026-10-07 16:53:23 -04:00
// The date of the day being swapped, while the swap sheet is open.
const [ swappingDate , setSwappingDate ] = useState < string | null >( 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 < string | null >( 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 )
2026-10-08 03:13:21 -04:00
// A push sends the plan shown: it waits for a draft or a swap of the week
// to settle, and neither starts while it runs, even across leaving the
// week and coming back. Nor does it send a plan from this device's cache,
// which may not be the server's: after a push refused because the plan
// changed, the read of the changed plan may be answered from the cache.
const cached = plan . isSuccess && plan . data !== null && plan . data . cached
const canPush = plan . isSuccess && plan . data !== null && ! cached && ! drafting && ! swap . pending && ! pushing
function pushToCalendar() {
if ( ! canPush ) return
swap . dismiss ()
push . reset ()
2026-10-08 15:16:51 -04:00
generate . reset ()
2026-10-08 03:13:21 -04:00
push . mutate ()
}
2026-10-07 16:53:23 -04:00
const { mutate } = startDraft
function start ( confirm : boolean ) {
setConfirming ( false )
2026-10-08 03:13:21 -04:00
// A draft asked for again leaves the last one's failure, a failed
// swap's and a failed push's behind.
2026-10-07 16:53:23 -04:00
setJobId ( null )
swap . dismiss ()
2026-10-08 03:13:21 -04:00
push . reset ()
2026-10-08 15:16:51 -04:00
generate . reset ()
2026-10-07 16:53:23 -04:00
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() {
2026-10-08 03:13:21 -04:00
if ( swap . pending || pushing ) return
2026-10-07 16:53:23 -04:00
if ( plan . data && pushedToCalendar ( plan . data . status )) setConfirming ( true )
else start ( false )
}
2026-10-08 15:16:51 -04:00
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
2026-10-08 03:13:21 -04:00
const failure = push . error !== null ? (
2026-10-08 15:16:51 -04:00
< 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 >
)
2026-10-08 03:13:21 -04:00
) : swap . failed !== null ? (
2026-10-11 01:21:55 -04:00
alreadyPlanned ( swap . failed . error ) ? (
// The plan is read again (useSwapDay): the household chooses another
// dinner from it rather than sending the refused swap again.
< ErrorBanner onRetry = {() => openSwap ( swap . failed ! . swap . date )} retryLabel = "Choose another dinner" >
Couldn 't swap {weekdayName(swap.failed.swap.date)}' s dinner . { swap . failed . swap . dinner . name } is already on another day
this week .
</ ErrorBanner >
) : (
< ErrorBanner onRetry = {() => swapIn ( swap . failed ! . swap )}>
Couldn 't swap {weekdayName(swap.failed.swap.date)}' s dinner . { swap . failed . error . message }
</ ErrorBanner >
)
2026-10-07 16:53:23 -04:00
) : startDraft . isError ? (
< ErrorBanner onRetry = { rebuild }> Couldn 't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn' t read the draft . { result . error . message }</ ErrorBanner >
) : unreadError !== null ? (
< ErrorBanner onRetry = {() => void plan . refetch ()}> Couldn 't read the draft. {unreadError.message}</ErrorBanner>
) : job.error === null ? null : job.status === ' failed ' ? (
<ErrorBanner onRetry={rebuild}>
Couldn' t draft this week . { draftFailures [ job . error . message ] ?? job . error . message }
</ ErrorBanner >
) : (
< ErrorBanner onRetry = { job . poll }> Couldn 't check on the draft. {job.error.message}</ErrorBanner>
)
2026-10-08 15:16:51 -04:00
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 >
)
2026-10-07 16:53:23 -04:00
2026-10-09 14:01:14 -04:00
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
// (useWeekSwap) and closes the sheet. The Supply run made again on the
// Plan after a push is left behind with the push.
2026-10-07 16:53:23 -04:00
function swapIn ( chosen : Swap ) {
setSwappingDate ( null )
2026-10-08 15:16:51 -04:00
generate . reset ()
2026-10-09 14:01:14 -04:00
sendSwap ( chosen )
2026-10-07 16:53:23 -04:00
}
2026-10-09 14:01:14 -04:00
// openSwap opens the swap sheet on a day, unless a swap is refused now
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
2026-10-07 16:53:23 -04:00
function openSwap ( date : string ) {
2026-10-09 14:01:14 -04:00
if ( refused ) return
2026-10-07 16:53:23 -04:00
swap . dismiss ()
setSwappingDate ( date )
}
let body
if ( plan . isError ) {
body = < ErrorBanner onRetry = {() => void plan . refetch ()}> Couldn 't load this week' s plan . { plan . error . message }</ ErrorBanner >
} else if ( drafting ) {
body = (
< SkeletonTiles label = "Drafting this week" layout = { layout }>
Drafting your week …
</ SkeletonTiles >
)
} else if ( resultError !== null ) {
// An unread draft shows no board: the plan read is not the draft.
body = null
} else if ( plan . isPending ) {
body = < SkeletonTiles label = "Plan loading" layout = { layout } />
} else if ( plan . data === null ) {
body = (
< EmptyState title = "Nothing planned yet" action = {{ label : 'Draft this week' , onClick : rebuild }}>
I 'll draft seven dinners from your Regulars and Quest log.
</EmptyState>
)
} else {
const { ai_note, days } = plan.data
const swappingDay = days.find((day) => day.date === swappingDate)
const sheet = swappingDay !== undefined && (
<SwapSheet
day={swappingDay}
planned={new Set(days.map((day) => day.dinner.id))}
onSwap={swapIn}
onClose={() => setSwappingDate(null)}
/>
)
body =
layout !== ' mobile ' ? (
<>
2026-10-09 14:01:14 -04:00
<DropBoard days={days} pending={refused} drop={swapIn} />
2026-10-07 16:53:23 -04:00
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
</ul>
{sheet}
</>
) : (
<>
{ai_note !== null && (
<div className={styles.assistant}>
<span className={styles.avatar} aria-hidden="true">
Q
</span>
<p className={styles.bubble}>{ai_note}</p>
</div>
)}
<ul className={styles.grid} aria-label="Dinners this week">
{days.map((day) => (
<DayTile
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
<li className={styles.rebuildCell}>
2026-10-08 03:13:21 -04:00
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending || pushing}>
2026-10-07 16:53:23 -04:00
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
2026-10-08 03:13:21 -04:00
<Button className={styles.pushButton} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
{pushing ? ' Pushing to Calendar … ' : ' Looks good — push to Calendar '}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
2026-10-08 15:16:51 -04:00
{shoppingList}
{supplyRunFailure}
2026-10-07 16:53:23 -04:00
{sheet}
</>
)
}
const tag = plan.data ? statusTags[plan.data.status] : null
const header =
layout !== ' mobile ' ? (
<header className={styles.wideHeader}>
<div className={styles.titleBar}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week} · Plan</p>
<h1 className={styles.wideTitle}>{label.dates}</h1>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
{layout === ' desktop ' && (
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
This week
</button>
)}
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</div>
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
2026-10-08 15:16:51 -04:00
{shoppingList}
2026-10-08 03:13:21 -04:00
<Button
variant="outline"
className={styles.action}
onClick={rebuild}
disabled={swap.pending || pushing || drafting || !plan.isSuccess}
>
2026-10-07 16:53:23 -04:00
<Icon icon={RefreshCw} size="inline" />
Rebuild week
</Button>
2026-10-08 03:13:21 -04:00
<Button className={styles.action} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
2026-10-07 16:53:23 -04:00
<Icon icon={CalendarDays} size="inline" />
2026-10-08 03:13:21 -04:00
{pushing ? ' Pushing … ' : ' Push to Calendar '}
</Button>
2026-10-07 16:53:23 -04:00
</div>
</div>
</header>
) : (
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>{label.dates}</h1>
{tag !== null && <Tag tone={tag.tone}>{tag.label}</Tag>}
</div>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</header>
)
return (
<>
{header}
{body}
{failure}
2026-10-08 15:16:51 -04:00
{pushed}
{layout !== ' mobile ' && supplyRunFailure}
2026-10-07 16:53:23 -04:00
<Confirm
open={confirming}
title="Rebuild this week?"
confirmLabel="Rebuild week"
cancelLabel="Keep this plan"
onCancel={() => setConfirming(false)}
onConfirm={() => start(true)}
>
This week is already pushed to Calendar. The AI assistant drafts a new plan in its place.
</Confirm>
</>
)
}
2026-10-08 03:13:21 -04:00
// SyncStatus is the plan' s status line , in the plan manager 's header and
// under the Draft board' s push button : whether the plan is on Google
// Calendar and, once synced, how long ago it was pushed, kept current minute
// by minute.
function SyncStatus ({ plan , className } : { plan : Plan ; className? : string }) {
2026-10-07 16:53:23 -04:00
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 (
2026-10-08 03:13:21 -04:00
< p className = {[ styles . status , status === 'synced' && styles . synced , className ]. filter ( Boolean ). join ( ' ' )}>
2026-10-07 16:53:23 -04:00
{ status === 'synced' && < Icon icon = { Check } size = "inline" />}
{ syncStatuses [ status ]}
{ status === 'synced' && syncedAt !== null && ` ${ ago ( syncedAt , now ) } ` }
</ p >
)
}
2026-10-08 03:13:21 -04:00
// PushFailure says why Push to Calendar failed. A failure only the Google
// Calendar settings can fix links there; any other offers to push again,
// or, while the plan shown is this device's cached copy, to read it again
// from the server first.
function PushFailure ({ error , cached , onRetry } : { error : Error ; cached : boolean ; onRetry : () => void }) {
const code = error instanceof ApiError ? error.code : null
const settings = code !== null && Object . hasOwn ( settingsFailures , code ) ? settingsFailures [ code ] : undefined
if ( settings !== undefined ) {
return (
< ErrorBanner >
Couldn 't push to Calendar. {settings.reason}{' '}
<Link to={CALENDAR_SETTINGS} className={styles.settingsLink}>
{settings.fix}
</Link>
</ErrorBanner>
)
}
return (
<ErrorBanner onRetry={onRetry}>
Couldn' t push to Calendar .{ ' ' }
{ cached
? ` ${ code === 'plan_changed' ? 'The plan changed while it was being pushed.' : error . message } The plan shown came from this device's cache, not the server; read it again before pushing.`
: code === 'plan_changed'
? 'The plan changed while it was being pushed; push it again.'
: error . message }
</ ErrorBanner >
)
}
2026-10-07 16:53:23 -04:00
// 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
2026-10-10 20:07:53 -04:00
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
// ?week=, as a plan_ready update links it, else on next week once this
2026-10-07 16:53:23 -04:00
// week's plan_draft time has come in the household's zone, otherwise on this
2026-10-10 20:07:53 -04:00
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
2026-10-07 16:53:23 -04:00
// 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.
2026-10-10 23:35:24 -04:00
// An upright tablet lays the week out four days over three.
2026-10-07 16:53:23 -04:00
export function PlanPage() {
const layout = useBreakpoint ()
2026-10-10 23:35:24 -04:00
const orientation = useOrientation ()
2026-10-07 16:53:23 -04:00
const settings = useHouseholdSettings ()
2026-10-10 20:07:53 -04:00
const [ params , setParams ] = useSearchParams ()
const chosen = params . get ( WEEK_PARAM )
// The default week is chosen once, not on every render: the plan_draft
// time passing does not move the page to another week.
const [ opened , setOpened ] = useState < string | null >( null )
if ( opened === null && settings . data ) setOpened ( defaultWeek ( settings . data , new Date ()))
const week = chosen ?? opened
const setWeek = ( weekStart : string ) => setParams ({ [ WEEK_PARAM ] : weekStart }, { replace : true })
2026-10-07 16:53:23 -04:00
let content
2026-10-10 20:07:53 -04:00
if ( chosen !== null && ! isMonday ( chosen )) {
content = < ErrorBanner >{ `“ ${ chosen } ” is not the Monday of a week, as YYYY-MM-DD.` }</ ErrorBanner >
} else if ( settings . isError ) {
2026-10-07 16:53:23 -04:00
content = (
< ErrorBanner onRetry = {() => void settings . refetch ()}> Couldn 't load the household settings. {settings.error.message}</ErrorBanner>
)
} else if (week === null || !settings.data) {
content = <SkeletonTiles label="Plan loading" layout={layout} />
} else {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
}
return (
<PlanDragProvider>
2026-10-10 23:35:24 -04:00
<div
className={[styles.page, styles[`${layout}Page`], layout === ' tablet ' && orientation === ' portrait ' && styles.portraitPage]
.filter(Boolean)
.join(' ')}
>
2026-10-07 16:53:23 -04:00
<div className={styles.board}>
{content}
{layout === ' tablet ' && <QuestLogStrip />}
</div>
{layout === ' desktop ' && <PlanPanel />}
{layout !== ' mobile ' && < DragAnnouncer />}
</ div >
</ PlanDragProvider >
)
}