Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #149.
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
|
/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
|
||||||
shell's 20 px side padding; from tablet up they sit in one column. The
|
shell's 20 px side padding. From tablet up the page is Kitchen reading
|
||||||
page is at least as tall as the shell's scroll area (a stretched flex
|
mode (mock 2a, below). The page is at least as tall as the shell's scroll
|
||||||
item, so its height resolves the percentage) and the sheet grows into
|
area (a stretched flex item, so its height resolves the percentage) and
|
||||||
any room left, so the action bar rests at the bottom of a short recipe. */
|
the sheet, or the reading view, grows into any room left, so the action
|
||||||
|
bar rests at the bottom of a short recipe. */
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,9 +12,11 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
|
||||||
.tablet,
|
.tablet,
|
||||||
.desktop {
|
.desktop {
|
||||||
max-width: 720px;
|
width: 100%;
|
||||||
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +30,6 @@
|
|||||||
margin: 0 -20px;
|
margin: 0 -20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tablet .hero,
|
|
||||||
.desktop .hero {
|
|
||||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Without a dinner to show, the back button sits alone above the message. */
|
/* Without a dinner to show, the back button sits alone above the message. */
|
||||||
.backRow {
|
.backRow {
|
||||||
padding-top: calc(16px + env(safe-area-inset-top));
|
padding-top: calc(16px + env(safe-area-inset-top));
|
||||||
@@ -249,3 +246,295 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
width: 70%;
|
width: 70%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mock 2a, Kitchen reading mode: the header over two columns, the
|
||||||
|
ingredients card (400 px) and the steps. Ingredient and step text scale
|
||||||
|
with the text size chosen: the handoff's sizes are medium. */
|
||||||
|
.reading {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
--reading-scale: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
--reading-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
--reading-scale: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .back {
|
||||||
|
position: static;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keepOn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
||||||
|
A drawn at the size it stands for. */
|
||||||
|
.readingHeader .textSize {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .textSize > * {
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .textSize > [aria-checked="true"] {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .textSize > :nth-child(1) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .textSize > :nth-child(2) {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingHeader .textSize > :nth-child(3) {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 400px minmax(0, 1fr);
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below a 1024 px viewport the shell leaves too little beside a 400 px
|
||||||
|
card for the steps, so the columns share the width instead. */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.columns {
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oneColumn {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns .ingredientsCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingSectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setOutCount {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow .check {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkName {
|
||||||
|
font-size: calc(18px * var(--reading-scale));
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setOut {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkQty {
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
font-size: calc(15px * var(--reading-scale));
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introPhoto {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introWhy {
|
||||||
|
flex: 1.6;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingSteps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readingStep {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 26px;
|
||||||
|
font-size: calc(18px * var(--reading-scale));
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepCircle {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepText {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDone {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepCurrent {
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--color-accent-100);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||||
|
font-size: calc(22px * var(--reading-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepCurrent .stepCircle {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The action bar rests at the bottom of the steps column, with Previous
|
||||||
|
and Next step at its right. */
|
||||||
|
.reading .bar {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading .add {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronLeft, Heart } from 'lucide-react'
|
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
||||||
import { ApiError } from '../api/errors.ts'
|
import { ApiError } from '../api/errors.ts'
|
||||||
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
|
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
|
||||||
import { duration } from '../plan/PlanPage.tsx'
|
import { duration } from '../plan/PlanPage.tsx'
|
||||||
import { weekdayName } from '../plan/week.ts'
|
import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
|
||||||
|
import { useHouseholdSettings } from '../settings/api.ts'
|
||||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import buttonStyles from '../ui/Button.module.css'
|
import buttonStyles from '../ui/Button.module.css'
|
||||||
|
import { Card } from '../ui/Card.tsx'
|
||||||
|
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
import { IconButton } from '../ui/IconButton.tsx'
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import { Photo } from '../ui/Photo.tsx'
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { Tag } from '../ui/Tag.tsx'
|
import { Tag } from '../ui/Tag.tsx'
|
||||||
|
import { Toggle } from '../ui/Toggle.tsx'
|
||||||
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
|
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
|
||||||
import { useToggleFavorite, type Difficulty } from './api.ts'
|
import { useToggleFavorite, type Difficulty } from './api.ts'
|
||||||
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
|
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
|
||||||
import styles from './DinnerDetailPage.module.css'
|
import styles from './DinnerDetailPage.module.css'
|
||||||
|
import { useKeepScreenOn } from './keepScreenOn.ts'
|
||||||
|
import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
|
||||||
|
|
||||||
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
|
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
|
||||||
export const REGULAR_DINNERS = '/settings/regulars'
|
export const REGULAR_DINNERS = '/settings/regulars'
|
||||||
@@ -77,9 +84,15 @@ function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipe is the dinner's ingredients and steps, or what stands in for them:
|
// hasRecipe says whether the dinner's recipe was found or typed in, rather
|
||||||
// still searching, or the link to fix a recipe that could not be found.
|
// than still being searched for or not found.
|
||||||
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
function hasRecipe(dinner: DinnerDetail): boolean {
|
||||||
|
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeMissing is what stands in for a recipe still being searched for, or
|
||||||
|
// the link to fix one that could not be found.
|
||||||
|
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
|
||||||
if (dinner.grounding_status === 'failed') {
|
if (dinner.grounding_status === 'failed') {
|
||||||
return (
|
return (
|
||||||
<section className={styles.missing} aria-label="No recipe">
|
<section className={styles.missing} aria-label="No recipe">
|
||||||
@@ -90,9 +103,12 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dinner.grounding_status === 'pending') {
|
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
||||||
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
}
|
||||||
}
|
|
||||||
|
// Recipe is the dinner's ingredients and steps, or what stands in for them.
|
||||||
|
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
||||||
|
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dinner.ingredients.length > 0 && (
|
{dinner.ingredients.length > 0 && (
|
||||||
@@ -206,12 +222,14 @@ function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
// ActionBar is the bar at the bottom of the screen, the page's last child:
|
// ActionBar is the bar at the bottom of the screen, the page's last child:
|
||||||
// the page fills the scroll area so a short recipe still leaves it at the
|
// the page fills the scroll area so a short recipe still leaves it at the
|
||||||
// bottom, and it sticks there over a long one. It holds Add to this week
|
// bottom, and it sticks there over a long one. It holds Add to this week
|
||||||
// and the favorite heart, with what became of either above them.
|
// and the favorite heart, with what became of either above them, and in
|
||||||
function ActionBar({ dinner }: { dinner: DinnerDetail }) {
|
// Kitchen reading mode the step buttons after them.
|
||||||
|
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.bar}>
|
<div className={styles.bar}>
|
||||||
<AddToWeek dinner={dinner} />
|
<AddToWeek dinner={dinner} />
|
||||||
<FavoriteHeart dinner={dinner} />
|
<FavoriteHeart dinner={dinner} />
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -251,9 +269,253 @@ function Detail({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the
|
// statusName is the dinner as the household calls it: a Regular, or a Side
|
||||||
// striped placeholder, under a sheet with Regular or Side quest, difficulty
|
// quest for any dinner not yet one.
|
||||||
// and time, why it was picked, the ingredients and the steps. A dinner whose
|
function statusName(dinner: DinnerDetail): string {
|
||||||
|
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
|
||||||
|
// quest", whenever this week's or next week's plan has it. Of the days that
|
||||||
|
// do, it is the first from today on in the household's timezone; when every
|
||||||
|
// one is already past, it is the latest of them. There is none while the
|
||||||
|
// plans are read, nor when neither plan has the dinner.
|
||||||
|
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
|
||||||
|
const today = localNow(timezone, new Date()).date
|
||||||
|
const thisWeek = weekStart(today)
|
||||||
|
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
|
||||||
|
const failed = plans.find((plan) => plan.isError)
|
||||||
|
if (failed !== undefined) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
|
||||||
|
Couldn't check the plan for this dinner's day. {failed.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
|
||||||
|
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
|
||||||
|
if (day === undefined) return null
|
||||||
|
return (
|
||||||
|
<p className={styles.kicker}>
|
||||||
|
{dayAndDate(day.date)} · {statusName(dinner)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kicker is PlannedDay, once the household's timezone is read.
|
||||||
|
function Kicker({ dinner }: { dinner: DinnerDetail }) {
|
||||||
|
const settings = useHouseholdSettings()
|
||||||
|
if (settings.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||||
|
Couldn't load the household settings to find this dinner's day. {settings.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (settings.isPending) return null
|
||||||
|
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSizes: SegmentedOption<TextSize>[] = [
|
||||||
|
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||||
|
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
|
||||||
|
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
|
||||||
|
// is planned for, its title, Keep screen on and the text size.
|
||||||
|
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
||||||
|
const keepOnId = useId()
|
||||||
|
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
|
||||||
|
const { failure } = textSize
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={styles.readingHeader}>
|
||||||
|
<BackButton />
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<Kicker dinner={dinner} />
|
||||||
|
<h1 className={styles.readingTitle}>{dinner.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.keepOn}>
|
||||||
|
<span id={keepOnId}>Keep screen on</span>
|
||||||
|
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
|
||||||
|
</div>
|
||||||
|
<Segmented
|
||||||
|
label="Text size"
|
||||||
|
options={textSizes}
|
||||||
|
value={textSize.size}
|
||||||
|
onValueChange={textSize.choose}
|
||||||
|
className={styles.textSize}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
{failure !== null && (
|
||||||
|
<ErrorBanner onRetry={textSize.retry}>
|
||||||
|
{textSizeFailureText[failure.action]}: {failure.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngredientsCard is the ingredients with a checkbox each, struck through
|
||||||
|
// once set out, and how many are. Which are set out is this visit's alone:
|
||||||
|
// it is never sent to the server and is not the Supply run's "have it".
|
||||||
|
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
|
||||||
|
const count = ingredients.filter((_, i) => setOut.has(i)).length
|
||||||
|
function toggle(i: number) {
|
||||||
|
setSetOut((before) => {
|
||||||
|
const after = new Set(before)
|
||||||
|
if (!after.delete(i)) after.add(i)
|
||||||
|
return after
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
|
||||||
|
<div className={styles.cardHead}>
|
||||||
|
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||||
|
Ingredients
|
||||||
|
</h2>
|
||||||
|
<span className={styles.setOutCount}>
|
||||||
|
{count} of {ingredients.length} set out
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className={styles.checklist}>
|
||||||
|
{ingredients.map((ingredient, i) => (
|
||||||
|
<li key={i} className={styles.checkRow}>
|
||||||
|
<Checkbox
|
||||||
|
className={styles.check}
|
||||||
|
checked={setOut.has(i)}
|
||||||
|
onChange={() => toggle(i)}
|
||||||
|
label={
|
||||||
|
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
|
||||||
|
{ingredient.name ?? ingredient.raw_text}
|
||||||
|
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.checkQty}>{quantity(ingredient)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps is the recipe's steps: those before current done, muted with a
|
||||||
|
// check; current highlighted in larger text; the rest still to come.
|
||||||
|
function Steps({ steps, current }: { steps: string[]; current: number }) {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={titleId} className={styles.readingSteps}>
|
||||||
|
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||||
|
Steps
|
||||||
|
</h2>
|
||||||
|
<ol className={styles.stepList}>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
aria-current={i === current ? 'step' : undefined}
|
||||||
|
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<span className={styles.stepCircle} aria-hidden="true">
|
||||||
|
{i < current ? <Icon icon={Check} /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={styles.stepText}>{step}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// typing says whether target is a field taking text, where the arrow keys
|
||||||
|
// move the caret and are not the screen's to take.
|
||||||
|
function typing(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false
|
||||||
|
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
|
||||||
|
}
|
||||||
|
|
||||||
|
// useCurrentStep is the step being cooked, of count, from the first, with
|
||||||
|
// move, which goes back or on by a step and stops at either end. The ← and →
|
||||||
|
// keys move it too, unless a text field has focus, a sheet is open over the
|
||||||
|
// screen or the key was already taken (a segmented control's arrows).
|
||||||
|
function useCurrentStep(count: number): [number, (by: number) => void] {
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const last = count - 1
|
||||||
|
const move = useCallback(
|
||||||
|
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
|
||||||
|
[last],
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||||
|
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
|
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
|
||||||
|
event.preventDefault()
|
||||||
|
move(by)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [move])
|
||||||
|
return [Math.max(0, Math.min(step, last)), move]
|
||||||
|
}
|
||||||
|
|
||||||
|
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
|
||||||
|
// then the ingredients to check off beside the photo, why it was picked and
|
||||||
|
// the steps, one at a time, over the action bar with Previous and Next step.
|
||||||
|
// What is checked and the step reached last only as long as this visit.
|
||||||
|
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
|
||||||
|
const textSize = useTextSize()
|
||||||
|
const [current, move] = useCurrentStep(dinner.steps.length)
|
||||||
|
const recipe = hasRecipe(dinner)
|
||||||
|
const last = dinner.steps.length - 1
|
||||||
|
return (
|
||||||
|
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
|
||||||
|
<ReadingHeader dinner={dinner} textSize={textSize} />
|
||||||
|
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
|
||||||
|
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
|
||||||
|
<div className={styles.method}>
|
||||||
|
<div className={styles.intro}>
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
|
||||||
|
{dinner.why_picked !== null && (
|
||||||
|
<p className={[styles.why, styles.introWhy].join(' ')}>
|
||||||
|
<b>Why it was picked:</b> {dinner.why_picked}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!recipe && <RecipeMissing dinner={dinner} />}
|
||||||
|
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
||||||
|
{recipe && dinner.source !== null && dinner.source.siteName !== null && (
|
||||||
|
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||||
|
)}
|
||||||
|
<ActionBar dinner={dinner}>
|
||||||
|
{recipe && dinner.steps.length > 0 && (
|
||||||
|
<div className={styles.stepButtons}>
|
||||||
|
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
|
||||||
|
<Icon icon={ChevronLeft} />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button disabled={current === last} onClick={() => move(1)}>
|
||||||
|
<Icon icon={ChevronRight} />
|
||||||
|
Next step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ActionBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
|
||||||
|
// photo, or the striped placeholder, under a sheet with Regular or Side
|
||||||
|
// quest, difficulty and time, why it was picked, the ingredients and the
|
||||||
|
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
|
||||||
// recipe could not be found links to Settings → Regular dinners to fix it;
|
// recipe could not be found links to Settings → Regular dinners to fix it;
|
||||||
// an unknown or removed dinner says it is not found.
|
// an unknown or removed dinner says it is not found.
|
||||||
export function DinnerDetailPage() {
|
export function DinnerDetailPage() {
|
||||||
@@ -292,7 +554,10 @@ export function DinnerDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
body = <Detail dinner={dinner.data} />
|
// Keyed by the dinner, so moving to another dinner starts its visit
|
||||||
|
// afresh: nothing checked, on the first step.
|
||||||
|
body =
|
||||||
|
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
|
||||||
}
|
}
|
||||||
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
|
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||||
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
|
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||||
|
import { TEXT_SIZE_KEY } from './textSize.ts'
|
||||||
|
|
||||||
|
const DINNER = '/api/v1/dinners/d1'
|
||||||
|
const SETTINGS = '/api/v1/settings/household'
|
||||||
|
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||||
|
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||||
|
|
||||||
|
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||||
|
// /api/v1/dinners/{id} sends it.
|
||||||
|
function detail(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'd1',
|
||||||
|
name: 'Korean beef bowls',
|
||||||
|
status: 'quest_log',
|
||||||
|
origin: 'suggested',
|
||||||
|
method: 'stovetop',
|
||||||
|
minutes_total: 30,
|
||||||
|
difficulty: 'easy',
|
||||||
|
cuisine: 'korean',
|
||||||
|
is_favorite: false,
|
||||||
|
photo_urls: null,
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||||
|
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||||
|
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Rinse the rice and start it cooking.',
|
||||||
|
'Brown the beef with the garlic and ginger.',
|
||||||
|
'Stir in the sauce and simmer until glossy.',
|
||||||
|
],
|
||||||
|
source: null,
|
||||||
|
grounding_status: 'grounded',
|
||||||
|
grounding_error: null,
|
||||||
|
why_picked: 'close cousin of your Beef tacos.',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
|
||||||
|
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
|
||||||
|
function planBody(weekStart: string, ...bowlsOn: number[]) {
|
||||||
|
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||||
|
return {
|
||||||
|
id: `plan-${weekStart}`,
|
||||||
|
week_start: weekStart,
|
||||||
|
status: 'draft',
|
||||||
|
ai_note: null,
|
||||||
|
calendar_synced_at: null,
|
||||||
|
days: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||||
|
dinner:
|
||||||
|
bowlsOn.includes(i)
|
||||||
|
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
|
||||||
|
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
|
||||||
|
is_new: bowlsOn.includes(i),
|
||||||
|
start_by: null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve answers the screen's reads: the dinner, the household settings and
|
||||||
|
// both weeks' plans, unless responses says otherwise.
|
||||||
|
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
|
||||||
|
const all: Record<string, () => Response> = {
|
||||||
|
[DINNER]: () => jsonResponse(200, detail()),
|
||||||
|
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
|
||||||
|
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
|
||||||
|
...responses,
|
||||||
|
}
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const path = String(input)
|
||||||
|
const respond = Object.hasOwn(all, path) ? all[path] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||||
|
return Promise.resolve(respond())
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return fetchMock
|
||||||
|
}
|
||||||
|
|
||||||
|
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||||
|
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPage renders the bowls' detail screen and waits for its title.
|
||||||
|
async function renderPage() {
|
||||||
|
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
|
||||||
|
initialEntries: ['/dinner/d1'],
|
||||||
|
})
|
||||||
|
const view = render(
|
||||||
|
<ApiProviders>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ApiProviders>,
|
||||||
|
)
|
||||||
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
function steps() {
|
||||||
|
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentStep is the text of the step marked current.
|
||||||
|
function currentStep() {
|
||||||
|
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
|
||||||
|
expect(current).toHaveLength(1)
|
||||||
|
return current[0]!.textContent
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockViewport(1180)
|
||||||
|
// Wednesday Oct 7, noon in New York.
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] })
|
||||||
|
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Kitchen reading mode', () => {
|
||||||
|
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
expect(declaredStyle(title).fontSize).toBe('36px')
|
||||||
|
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||||
|
// Not the phone's sheet: no photo hero tags.
|
||||||
|
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
|
||||||
|
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
|
||||||
|
'Small text',
|
||||||
|
'Medium text',
|
||||||
|
'Large text',
|
||||||
|
])
|
||||||
|
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
|
||||||
|
'400px minmax(0, 1fr)',
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kicker', () => {
|
||||||
|
it('names the day the plan has the dinner on, and Side quest', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("names a day of next week's plan, and Regular, for a Regular", async () => {
|
||||||
|
serve({
|
||||||
|
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
|
||||||
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
|
||||||
|
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("names a day of this week's plan already past when that is the only day", async () => {
|
||||||
|
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
|
||||||
|
serve({
|
||||||
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
|
||||||
|
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the first day from today on over a day already past', async () => {
|
||||||
|
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the latest day already past when every day is past', async () => {
|
||||||
|
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is absent when neither week has a plan', async () => {
|
||||||
|
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
|
||||||
|
await renderPage()
|
||||||
|
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
|
||||||
|
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
|
||||||
|
await act(async () => {})
|
||||||
|
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when the plan cannot be read, with Retry', async () => {
|
||||||
|
let failing = true
|
||||||
|
serve({
|
||||||
|
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
failing = false
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ingredients', () => {
|
||||||
|
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
|
||||||
|
const fetchMock = serve()
|
||||||
|
await renderPage()
|
||||||
|
const card = screen.getByRole('region', { name: 'Ingredients' })
|
||||||
|
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
|
||||||
|
expect(declaredStyle(beef).width).toBe('22px')
|
||||||
|
expect(declaredStyle(beef).height).toBe('22px')
|
||||||
|
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
|
||||||
|
|
||||||
|
fireEvent.click(beef)
|
||||||
|
expect(beef).toBeChecked()
|
||||||
|
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
|
||||||
|
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
|
||||||
|
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(beef)
|
||||||
|
expect(beef).not.toBeChecked()
|
||||||
|
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
|
||||||
|
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Local to the screen: nothing is sent.
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts afresh on the next visit', async () => {
|
||||||
|
serve()
|
||||||
|
const view = await renderPage()
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
|
||||||
|
view.unmount()
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('steps', () => {
|
||||||
|
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const previous = screen.getByRole('button', { name: 'Previous' })
|
||||||
|
const next = screen.getByRole('button', { name: 'Next step' })
|
||||||
|
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
expect(previous).toBeDisabled()
|
||||||
|
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
|
||||||
|
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
|
||||||
|
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
||||||
|
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||||
|
|
||||||
|
fireEvent.click(next)
|
||||||
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||||
|
// The step done is muted, its number a check.
|
||||||
|
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
||||||
|
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
||||||
|
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
|
||||||
|
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||||
|
|
||||||
|
fireEvent.click(next)
|
||||||
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||||
|
expect(next).toBeDisabled()
|
||||||
|
fireEvent.click(next)
|
||||||
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||||
|
|
||||||
|
fireEvent.click(previous)
|
||||||
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves by the ← and → keys, stopping at either end', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||||
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||||
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||||
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
|
||||||
|
// From a checked ingredient too.
|
||||||
|
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
|
||||||
|
beef.focus()
|
||||||
|
fireEvent.keyDown(beef, { key: 'ArrowRight' })
|
||||||
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const field = document.createElement('input')
|
||||||
|
document.body.append(field)
|
||||||
|
field.focus()
|
||||||
|
fireEvent.keyDown(field, { key: 'ArrowRight' })
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
field.remove()
|
||||||
|
|
||||||
|
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
|
||||||
|
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
||||||
|
fireEvent.keyDown(medium, { key: 'ArrowRight' })
|
||||||
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('text size', () => {
|
||||||
|
it('is remembered on this device', async () => {
|
||||||
|
serve()
|
||||||
|
const view = await renderPage()
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
|
||||||
|
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
|
||||||
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
view.unmount()
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
|
||||||
|
'1.25',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
|
||||||
|
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
|
||||||
|
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
|
||||||
|
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
||||||
|
serve({
|
||||||
|
[DINNER]: () =>
|
||||||
|
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
|
||||||
|
// is on, and a setter. It starts off on each visit.
|
||||||
|
//
|
||||||
|
// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
|
||||||
|
// the switch's state; nothing keeps the screen awake yet.
|
||||||
|
export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
|
||||||
|
return useState(false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
|
||||||
|
// size chosen in Kitchen reading mode.
|
||||||
|
export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
|
||||||
|
|
||||||
|
export const TEXT_SIZES = ['small', 'medium', 'large'] as const
|
||||||
|
|
||||||
|
export type TextSize = (typeof TEXT_SIZES)[number]
|
||||||
|
|
||||||
|
// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
|
||||||
|
// handoff's 18 px ingredients and steps.
|
||||||
|
export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
|
||||||
|
|
||||||
|
// TextSizeState is the text size, and the storage failure, if any, that
|
||||||
|
// stopped us reading or saving it.
|
||||||
|
type TextSizeState = {
|
||||||
|
size: TextSize
|
||||||
|
failure: { action: 'read' | 'write'; message: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextSize(value: string): value is TextSize {
|
||||||
|
return (TEXT_SIZES as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage can be unavailable (blocked by the browser or privacy settings), in
|
||||||
|
// which case using it throws, and can hold a value MealQuest did not save.
|
||||||
|
// Either is reported, never treated as a remembered size.
|
||||||
|
function readTextSize(): TextSizeState {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
|
||||||
|
if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
|
||||||
|
if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
|
||||||
|
return { size: saved, failure: null }
|
||||||
|
} catch (err) {
|
||||||
|
return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTextSize(size: TextSize): TextSizeState {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TEXT_SIZE_KEY, size)
|
||||||
|
return { size, failure: null }
|
||||||
|
} catch (err) {
|
||||||
|
return { size, failure: { action: 'write', message: String(err) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const textSizeFailureText = {
|
||||||
|
read: 'Could not read the text size chosen on this device',
|
||||||
|
write: 'Could not remember the text size on this device',
|
||||||
|
}
|
||||||
|
|
||||||
|
// useTextSize is Kitchen reading mode's text size on this device: the size,
|
||||||
|
// the storage failure if any, choose, which saves a size, and retry, which
|
||||||
|
// reads or saves again whatever failed. A size that could not be saved is
|
||||||
|
// still used until the screen is left.
|
||||||
|
export function useTextSize() {
|
||||||
|
const [state, setState] = useState<TextSizeState>(readTextSize)
|
||||||
|
const choose = (size: TextSize) => setState(writeTextSize(size))
|
||||||
|
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
|
||||||
|
return { ...state, choose, retry }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||||
|
|
||||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||||
@@ -36,6 +36,11 @@ describe('week helpers', () => {
|
|||||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('names the day of a date in full with its month and day', () => {
|
||||||
|
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
|
||||||
|
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
|
||||||
|
})
|
||||||
|
|
||||||
it('reads the day of the month of a date', () => {
|
it('reads the day of the month of a date', () => {
|
||||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export function weekdayName(date: string): string {
|
|||||||
return weekday.format(midnightUTC(date))
|
return weekday.format(midnightUTC(date))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||||
|
|
||||||
|
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
|
||||||
|
export function dayAndDate(date: string): string {
|
||||||
|
return longDay.format(midnightUTC(date))
|
||||||
|
}
|
||||||
|
|
||||||
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||||
function monday(start: string): Date {
|
function monday(start: string): Date {
|
||||||
const t = midnightUTC(start)
|
const t = midnightUTC(start)
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ describe('Segmented', () => {
|
|||||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('names an option by its ariaLabel when it has one', () => {
|
||||||
|
const sizes = [
|
||||||
|
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||||
|
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||||
|
] as const
|
||||||
|
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
|
||||||
|
expect(radio('Small text')).toHaveTextContent('A')
|
||||||
|
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
it('fails fast when the value is not one of the options', () => {
|
it('fails fast when the value is not one of the options', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
|
|||||||
export type SegmentedOption<T extends string> = {
|
export type SegmentedOption<T extends string> = {
|
||||||
value: T
|
value: T
|
||||||
label: string
|
label: string
|
||||||
|
// Accessible name, when the visible label alone does not say what the
|
||||||
|
// option is (the A / A / A of a text size).
|
||||||
|
ariaLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SegmentedProps<T extends string> = {
|
export type SegmentedProps<T extends string> = {
|
||||||
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
|
|||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
|
aria-label={option.ariaLabel}
|
||||||
tabIndex={checked ? 0 : -1}
|
tabIndex={checked ? 0 : -1}
|
||||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||||
onClick={() => select(index)}
|
onClick={() => select(index)}
|
||||||
|
|||||||
Reference in New Issue
Block a user