From 768 px up /plan renders the plan manager: "Week N · Plan" kicker, the week's dates, previous/next buttons and, on desktop, a "This week" pill; the sync status line; "Shopping list" and "Push to Calendar" unavailable with tooltips saying why (Features 17 and 16), and a working outlined "Rebuild week" that confirms over a plan pushed to Calendar; and a 7-column grid of day cards with washed photo, name, minutes, crock-pot start-by time, Regular / Side quest tag (Side quests on teal-200) and a swap icon that opens the existing swap sheet. Desktop adds the 290 px right panel: "Quest log · N | Regulars · N", the drag helper, rows with photo, name, difficulty and a 6-dot grip, and "Swipe more side quests". Tablet shows the Quest log as a chip strip under the grid. Dragging is presentational here; it lands in task 5c2842a8. The plan day now reads the photo and minutes the plan API already sends, and an optimistic swap carries them from the chosen dinner. Tag gains a neutral tone for "Regular". Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
import { useId, useState } from 'react'
|
|
import { useRegulars } from '../dinners/api.ts'
|
|
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
|
import { details } from '../discover/QuestLogSheet.tsx'
|
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
|
import { ListRow } from '../ui/ListRow.tsx'
|
|
import { Photo } from '../ui/Photo.tsx'
|
|
import { Segmented } from '../ui/Segmented.tsx'
|
|
import { Sheet } from '../ui/Sheet.tsx'
|
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
|
import { Tag } from '../ui/Tag.tsx'
|
|
import type { PlanDay, Swap } from './api.ts'
|
|
import { weekdayName } from './week.ts'
|
|
import styles from './SwapSheet.module.css'
|
|
|
|
// Source is a list a dinner is swapped in from.
|
|
export type Source = 'quest_log' | 'regulars'
|
|
|
|
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
|
|
|
export const emptyLists: Record<Source, string> = {
|
|
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
|
|
regulars: 'You have no Regulars yet.',
|
|
}
|
|
|
|
export type SwapSheetProps = {
|
|
// The day being swapped.
|
|
day: PlanDay
|
|
// The ids of the dinners already on the week's plan.
|
|
planned: ReadonlySet<string>
|
|
onSwap: (swap: Swap) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
|
|
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
|
|
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
|
|
// dinner swaps it into the day; a dinner already this week can't be chosen.
|
|
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
|
const titleId = useId()
|
|
const questLog = useQuestLog()
|
|
const regulars = useRegulars()
|
|
const [source, setSource] = useState<Source>('quest_log')
|
|
const lists = { quest_log: questLog, regulars }
|
|
const list = lists[source]
|
|
|
|
const label = (s: Source) => {
|
|
const dinners = lists[s].data
|
|
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
|
}
|
|
|
|
let body
|
|
if (list.isError) {
|
|
body = (
|
|
<ErrorBanner onRetry={() => void list.refetch()}>
|
|
Couldn't load your {sourceNames[source]}. {list.error.message}
|
|
</ErrorBanner>
|
|
)
|
|
} else if (list.isPending) {
|
|
body = (
|
|
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
|
|
{Array.from({ length: 3 }, (_, i) => (
|
|
<Skeleton key={i} className={styles.skeletonRow} />
|
|
))}
|
|
</div>
|
|
)
|
|
} else if (list.data.length === 0) {
|
|
body = <p className={styles.empty}>{emptyLists[source]}</p>
|
|
} else {
|
|
body = (
|
|
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
|
{list.data.map((dinner: QuestLogDinner) => {
|
|
const already = planned.has(dinner.id)
|
|
return (
|
|
<li key={dinner.id}>
|
|
<ListRow
|
|
className={already ? styles.already : undefined}
|
|
leading={
|
|
// The row's name already says what the photo shows.
|
|
<span aria-hidden="true">
|
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
|
</span>
|
|
}
|
|
title={dinner.name}
|
|
meta={details(dinner)}
|
|
// A dinner already this week is no button: it can't be chosen.
|
|
onClick={
|
|
already
|
|
? undefined
|
|
: () =>
|
|
onSwap({
|
|
date: day.date,
|
|
dinner: { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total },
|
|
fromQuestLog: source === 'quest_log',
|
|
})
|
|
}
|
|
trailing={already && <Tag>Already this week</Tag>}
|
|
/>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
|
<div className={styles.sheet}>
|
|
<div>
|
|
<h2 id={titleId} className={styles.title}>
|
|
Swap {weekdayName(day.date)}
|
|
</h2>
|
|
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
|
|
</div>
|
|
<Segmented
|
|
label="Swap in from"
|
|
options={[
|
|
{ value: 'quest_log', label: label('quest_log') },
|
|
{ value: 'regulars', label: label('regulars') },
|
|
]}
|
|
value={source}
|
|
onValueChange={setSource}
|
|
/>
|
|
{body}
|
|
</div>
|
|
</Sheet>
|
|
)
|
|
}
|