import { Check, ChevronLeft } from 'lucide-react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router' import { Button } from '../ui/Button.tsx' import { Card } from '../ui/Card.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Select } from '../ui/Select.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { TextInput } from '../ui/TextInput.tsx' import { householdSettingsProblems, useHouseholdSettings, useSaveHouseholdSettings, type HouseholdSettings, type HouseholdSettingsField, type HouseholdSettingsPatch, } from './api.ts' import styles from './HouseholdSettingsPage.module.css' import { LeaveGuard } from './LeaveGuard.tsx' import { matchesZone, timeZoneOptions, utcOffset, zoneLabel } from './timezones.ts' // Days of the week as the server counts them, from Sunday = 0. const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const // Settings · Household: the time zone and the times the household's // reminders and weekly AI runs happen at. Every field is edited as a draft // and saved together; only the changed fields are sent. export function HouseholdSettingsPage() { const [dirty, setDirty] = useState(false) return (

Changes apply to everyone in the household.

) } type HouseholdSettingsProps = { // Called with whether the form holds unsaved changes. onDirtyChange: (dirty: boolean) => void } // HouseholdSettingsSections is the screen's form once its settings load, // without the screen's header, so the desktop settings (mock 2e) can embed // it. export function HouseholdSettingsSections({ onDirtyChange }: HouseholdSettingsProps) { const settings = useHouseholdSettings() return ( <> {settings.isPending && } {settings.isError && ( void settings.refetch()}> Household settings could not load: {settings.error.message} )} {settings.isSuccess && } ) } function Header() { const navigate = useNavigate() return (
void navigate('/settings')}>

Settings

Household

) } // patchOf is the PATCH body for the fields draft changes. function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch { const patch: Record = {} for (const field of Object.keys(saved) as HouseholdSettingsField[]) { if (draft[field] !== saved[field]) patch[field] = draft[field] } return patch as HouseholdSettingsPatch } type HouseholdSettingsFormProps = HouseholdSettingsProps & { saved: HouseholdSettings } function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) { const [draft, setDraft] = useState(saved) const save = useSaveHouseholdSettings() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 const problems = save.isError ? householdSettingsProblems(save.error) : { fields: {}, other: [] } useEffect(() => { onDirtyChange(dirty) }, [dirty, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) function edit(change: Partial) { save.reset() setDraft((current) => ({ ...current, ...change })) } function onSave() { save.mutate(patch, { onSuccess: setDraft }) } return (
edit({ timezone })} error={problems.fields.timezone} />

Every time on this screen is in this zone.

{save.isSuccess && !dirty && 'Changes saved.'}
{save.isError && (
{Object.keys(problems.fields).length > 0 && (

Not saved: fix the settings marked below their fields.

)} {problems.other.map((reason) => (

Not saved: {reason}

))}
)}
) } function Section({ title, children }: { title: string; children: ReactNode }) { const titleId = useId() return (

{title}

{children}
) } function FieldError({ id, error }: { id: string; error: string | undefined }) { if (error === undefined) return null return (

{error}

) } type FieldProps = { draft: HouseholdSettings edit: (change: Partial) => void problems: Partial> label: string hint: string } type TimeFieldProps = FieldProps & { name: 'dinner_time' | 'reminder_time' | 'review_time' } function TimeField({ draft, edit, problems, name, label, hint }: TimeFieldProps) { const hintId = useId() const errorId = useId() const error = problems[name] return (
edit({ [name]: event.target.value })} />

{hint}

) } type DayTimeFieldProps = FieldProps & { day: 'plan_draft_dow' | 'suggestions_dow' | 'chat_autosend_dow' time: 'plan_draft_time' | 'suggestions_time' | 'chat_autosend_time' } function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTimeFieldProps) { const hintId = useId() const dayErrorId = useId() const timeErrorId = useId() const dayError = problems[day] const timeError = problems[time] return (
edit({ [time]: event.target.value })} />

{hint}

) } type TimeZoneFieldProps = { value: string saved: string onChange: (zone: string) => void error: string | undefined } // TimeZoneField shows the chosen zone and its current UTC offset; Change // opens a searchable list of every IANA zone. function TimeZoneField({ value, saved, onChange, error }: TimeZoneFieldProps) { const [open, setOpen] = useState(false) const labelId = useId() const errorId = useId() const offset = useMemo(() => utcOffset(value, new Date()), [value]) return (
Time zone
{zoneLabel(value)} {offset}
{open && ( setOpen(false)} onChoose={(zone) => { onChange(zone) setOpen(false) }} /> )}
) } type TimeZoneSheetProps = { value: string // The saved zone, offered even after another is chosen. saved: string onClose: () => void onChoose: (zone: string) => void } function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) { const [search, setSearch] = useState('') const titleId = useId() const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, value]) const shown = options.filter((option) => matchesZone(option, search)) return (

Choose a time zone

setSearch(event.target.value)} />

{shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`}

    {shown.map((option) => { const current = option.zone === value return (
  • ) })}
) }