import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSendToChat,
useSupplyRun,
useToggles,
WEEK_PARAM,
type SupplyRunGroup,
type SupplyRunItem,
type Toggles,
} from './api.ts'
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
}
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. A read-only Supply run shows the
// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
<>
{item.name}
{toggles?.willSync.has(item.id) && (
will sync
)}
>
)
return (
)
}
// GroupCard is one dinner's items: its name in Bagel over its day.
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
return (
{group.dinner.name}
{weekdayKicker(group.date)}
{group.items.map((item) => (
))}
)
}
// SentStatus is the status line once the Supply run was sent to Google Chat.
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
Sent to Google Chat · {sentAt(at, timezone)}
)
}
// UnconfirmedStatus is the status line while a send to Google Chat whose
// outcome is not known is recorded: it may or may not have been posted.
function UnconfirmedStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
Send to Google Chat not confirmed · {sentAt(at, timezone)} — check the space before sending again
)
}
// SendToChat posts the Supply run to Google Chat: "Send to Google Chat"
// until it is sent, or a send is unconfirmed, then "Send again", which
// posts it once more.
function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) {
return (
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
)
}
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header; a send that fails says why, and one whose outcome is not known
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
const sent = run.sent_to_chat_at !== null
// A send whose outcome is not known is posted again only on request.
const resend = sent || run.chat_send_unconfirmed_at !== null
const unconfirmed = !send.isPending && run.chat_send_unconfirmed_at !== null && (
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
return (
Supply run · {weekLabel(weekStart).dates}
Shopping list
{viewOnly && Archived}
{run.needed_count} / {run.total_count}
{run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.'
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
{wide && (
{!viewOnly && send.mutate(resend)} />}
)}
{failed !== null && (
toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message}
)}
{send.isError && !viewOnly && (
send.mutate(resend)}>
Couldn't send the shopping list to Google Chat. {send.error.message}
)}
{wide && run.sent_to_chat_at !== null && }
{wide && unconfirmed}
{run.groups.length === 0 ? (
None of this week's dinners has a recipe with ingredients yet.
) : (
{run.groups.map((group) => (
))}
)}
{!wide && (
)}
)
}
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
return (
{Array.from({ length: 3 }, (_, i) => (
))}
)
}
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
// Offline, the Supply run saved on this device stays shown; the offline
// pill says it is saved data.
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
return void run.refetch()}>Couldn't load the shopping list. {run.error.message}
}
if (run.isPending || generate.isPending) return
if (run.data === null) {
return (
{readOnly ? (
This week has no Supply run in the Supply archive.
) : (
generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
)}
{generate.isError && (
generate.mutate()}>Couldn't make the shopping list. {generate.error.message}
)}
)
}
return
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
Changes to the shopping list can't be kept on this device while offline. {problem.message}
)}
>
)
}
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
// Read once, not on every render: a failed read reports a problem, which
// renders this again.
const [saved] = useState(() => keep(savedSchedule))
if (chosen !== null && !isMonday(chosen)) {
return {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}
}
// fetch rejects with a TypeError only when the network fails.
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
if (settings.isError && !schedule) {
return void settings.refetch()}>Couldn't load the household settings. {settings.error.message}
}
if (!schedule) return
const week = chosen ?? defaultWeek(schedule, new Date())
return
}