Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d69852b045 Find the actual next local midnight across clock changes (F21-03)
CI / ci (push) Successful in 9m4s
CI / ci (pull_request) Successful in 8m11s
untilLocalMidnight subtracted the local clock time from 24 hours, so on a
23-hour spring-forward day Home refreshed an hour after midnight. It now
computes the instant the next calendar day starts in the household zone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:18 -04:00
dev_agentandClaude Opus 5.5 0a69efea5c Fix Home dashboard integration findings (Feature 21)
CI / ci (pull_request) Successful in 8m27s
CI / ci (push) Successful in 8m57s
- F21-01: plan_ready and list_ready links carry their week
  (/plan?week=, /plan/shopping?week=), and the Plan opens on ?week=
  and keeps it on the week shown, so an older update opens its week.
- F21-02: a failed refresh of the Rotation check shows its ErrorBanner
  beside the cached pending card instead of replacing it.
- F21-03: Home carries the household's timezone and is read again at
  its next local midnight.
- F21-04: Home counts pending Side quests through a read-only query
  (suggest.PendingCount), so reading Home never writes batches.

Task: ac3dddf0-a05f-4e43-a5ad-3105fe6bdd95

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:03:52 -04:00
dev_agent 62f4f9eece Keep it in rotation card (Feature 21) (#179)
CI / ci (push) Successful in 8m9s
CI / ci (pull_request) Successful in 9m23s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 17:13:20 -04:00
orchestrator-agent 837da07f3e Recent updates sheet (Feature 21) (#176)
CI / ci (push) Successful in 9m7s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-10 15:48:26 -04:00
28 changed files with 946 additions and 60 deletions
+9 -5
View File
@@ -793,13 +793,13 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job stores the
AI's draft of a week. Only the first draft of a week notifies: a
redraft (Rebuild week) has the same key, and a manual swap never
drafts. Its title is "Next week's plan is ready" (or "This week's",
or "The plan for the week of Oct 26") and its body the drafted
dinners Monday to Sunday.
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
@@ -1098,6 +1098,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
```json
{
"dateLabel": "Friday, Oct 2",
"timezone": "America/Chicago",
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
"sideQuestCount": 10,
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
@@ -1107,8 +1108,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
`tonight` is today's day of the current week's plan (`plan.Get`), null
when the week has no plan; it changes at the household's local
midnight. `photo` and `minutes_total` are null when unknown.
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
midnight, so the PWA reads Home again at the next local midnight of
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
null when unknown. `sideQuestCount` is the pending cards of the deck,
read without writing (`suggest.PendingCount`): a Home read never fails
orphaned batches the way a `suggest.Deck` read does.
`shoppingList` is the upcoming week's Supply run when it has one, else
the current week's (`shopping.Get`); with neither, it is the current
week with `ready` false. `recentUpdates.unread` is true when a
@@ -1123,7 +1127,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
one update):
```json
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
```
Every field comes from the stored payload, which never holds a webhook
+2 -2
View File
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
t.Errorf("plan text = %q", reqs[0].Body["text"])
}
// The cards' "Open" buttons link under the public URL.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
// The cards' "Open" buttons link under the public URL to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
+3 -2
View File
@@ -2,6 +2,7 @@ package chat
import (
"fmt"
"net/url"
"strings"
"time"
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan",
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
URL: "/plan/shopping",
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
+6 -6
View File
@@ -38,7 +38,7 @@ func TestMessages(t *testing.T) {
name: "plan ready",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 4 more",
@@ -48,7 +48,7 @@ func TestMessages(t *testing.T) {
name: "plan ready with three dinners",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala",
@@ -58,7 +58,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more",
@@ -68,7 +68,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26",
url: "/plan",
url: "/plan?week=2026-10-26",
week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready",
body: "Tacos",
@@ -78,7 +78,7 @@ func TestMessages(t *testing.T) {
name: "list ready",
ev: ListReady(week, 19, 7),
key: "list_ready:2026-10-12",
url: "/plan/shopping",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "19 items to buy for 7 dinners",
@@ -88,7 +88,7 @@ func TestMessages(t *testing.T) {
name: "list ready singular",
ev: ListReady(week, 1, 1),
key: "list_ready:2026-10-12",
url: "/plan/shopping",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "1 item to buy for 1 dinner",
+8 -3
View File
@@ -50,6 +50,9 @@ func NewService(settings *household.SettingsService, plans *plan.Service, sugges
type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"`
// Timezone is the household's IANA zone, whose next local midnight
// is when Home is read again for the new day.
Timezone string `json:"timezone"`
// Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan.
Tonight *Dinner `json:"tonight"`
@@ -92,7 +95,8 @@ type RecentUpdates struct {
Unread bool `json:"unread"`
}
// Get reads the Home screen at the service's "now".
// Get reads the Home screen at the service's "now". It only reads: it
// writes nothing, not even the batch bookkeeping a Deck read does.
func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx)
if err != nil {
@@ -110,7 +114,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
if err != nil {
return Home{}, err
}
deck, err := s.suggest.Deck(ctx)
sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err)
}
@@ -124,8 +128,9 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
}
return Home{
DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight,
SideQuestCount: len(deck.Suggestions),
SideQuestCount: sideQuests,
ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread},
}, nil
+27
View File
@@ -245,6 +245,7 @@ func TestHomeEmpty(t *testing.T) {
h := e.at(t, "2026-10-13 23:59")
want := home.Home{
DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
},
@@ -289,6 +290,32 @@ func TestSideQuestCount(t *testing.T) {
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// The Supply run shown is the upcoming week's when it has one, else the
// current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) {
+2 -2
View File
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
}
}
+1
View File
@@ -35,6 +35,7 @@ func TestGetHome(t *testing.T) {
}
body, err := json.Marshal(map[string]any{
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
"timezone": loc.String(),
"tonight": nil,
"sideQuestCount": 0,
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
+2 -2
View File
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
}
}
+11
View File
@@ -346,6 +346,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
return deck, nil
}
// PendingCount is how many Side quests are pending on the deck. Unlike
// Deck it only reads: it does not fail orphaned batches, so a reader such
// as Home never writes.
func (s *Service) PendingCount(ctx context.Context) (int, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return 0, fmt.Errorf("suggest: list deck: %w", err)
}
return len(rows), nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined.
+9 -3
View File
@@ -88,7 +88,7 @@ describe('App routes', () => {
it('/ renders the Home screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
renderAt('/')
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
expect(screen.getByRole('main')).toContainElement(heading)
@@ -273,7 +273,13 @@ describe('App bootstrap', () => {
it('reports a /me failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
stubFetchByPath({
...SIGNED_IN,
'/api/v1/me': () => mes.shift()!,
'/api/v1/home': () => jsonResponse(200, HOME),
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
})
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
@@ -294,7 +300,7 @@ describe('App offline bootstrap', () => {
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
unmount()
+35 -4
View File
@@ -1,12 +1,13 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
// NOTHING_YET is the home of a household with no plan, no Side quests, no
// Supply run and nothing unread.
@@ -19,6 +20,7 @@ const NOTHING_YET = {
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -29,7 +31,7 @@ function Where() {
}
function renderHome(body: unknown) {
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
return renderRouter()
}
@@ -71,6 +73,31 @@ describe('HomePage', () => {
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
})
it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
// setTimeout moves with real time, and the test jumps to midnight.
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
// 23:58 on Friday Oct 2 in New York, the household's zone.
vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
let reads = 0
stubFetchByPath({
[HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
})
renderRouter()
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(reads).toBe(1)
// A minute before midnight Home is not read again.
await act(() => vi.advanceTimersByTimeAsync(60_000))
expect(reads).toBe(1)
// At midnight it is, and shows the new day.
await act(() => vi.advanceTimersByTimeAsync(60_000))
expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
expect(reads).toBe(2)
expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
})
it('shows the grape unread dot on the bell when there are unread updates', async () => {
renderHome(HOME)
@@ -198,7 +225,11 @@ describe('HomePage', () => {
})
it('reports a failure with Retry, and no made-up content', async () => {
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
stubFetchByPath({
[HOME_PATH]: () => homes.shift()!,
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
})
renderRouter()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
+4 -1
View File
@@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
import styles from './HomePage.module.css'
import { RotationCheck } from './RotationCheck.tsx'
import { UpdatesSheet } from './UpdatesSheet.tsx'
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
@@ -108,7 +109,8 @@ function Tiles() {
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
// Recent updates bell, the Tonight card (or the way to plan the week), the
// Quick dinner and Cook from what I have tiles, and the status rows, all
// from GET /api/v1/home.
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
// rotation?", while a tried dinner waits for the household's answer.
//
// The bell carries a grape dot while there are unread updates and opens the
// Recent updates sheet, which marks them seen.
@@ -141,6 +143,7 @@ export function HomePage() {
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
)}
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
<RotationCheck />
<Tiles />
{home.isPending && (
<div className={styles.statuses} aria-hidden="true">
+79
View File
@@ -0,0 +1,79 @@
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
.card {
display: flex;
flex-direction: column;
border-radius: 32px;
background: var(--color-surface);
overflow: hidden;
}
.photo {
height: 120px;
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 18px 12px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.question {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 6px;
}
/* "We didn't make it": a text link at the 44 px tap minimum. */
.notMade {
align-self: center;
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.notMade:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.notMade:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.confirmation {
margin: 0;
padding: 12px 14px;
border-radius: 20px;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
+320
View File
@@ -0,0 +1,320 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
import { QueryClientProvider } from '@tanstack/react-query'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
// /api/v1/rotation-reviews sends them.
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'serviceWorker')
})
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
// stubApi answers Home's reads like the server: the pending Rotation checks
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
// decisions until the test answers it; a 200 answer takes the check off the
// server's list, as the server does.
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
const server = [...reviews]
const decisions: Deferred[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
if (init?.method === 'POST' && review !== undefined) {
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
const body: unknown = JSON.parse(String(init.body))
return new Promise<Response>((resolve) =>
decisions.push({
path,
body,
resolve: (response) => {
if (response.ok) server.splice(server.indexOf(review), 1)
resolve(response)
},
}),
)
}
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, decisions, server }
}
// decided is the server's answer to a decision on review.
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
return jsonResponse(200, {
dinner: review.dinner,
plan_date: review.plan_date,
decision,
dinner_status: dinnerStatus,
decided_at: '2026-10-02T13:00:00Z',
updated_by: 'sub-1',
})
}
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
// at path.
async function renderHome(path = '/') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
}
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
describe('RotationCheck', () => {
it('shows no card when nothing waits for an answer', async () => {
const { fetchMock } = stubApi([])
await renderHome()
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
})
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
const keep = within(check).getByRole('button', { name: 'Keep it' })
const drop = within(check).getByRole('button', { name: 'Not for us' })
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
expect(keep.className).toMatch(/primary/)
expect(drop.className).toMatch(/outline/)
expect(notMade.className).toMatch(/notMade/)
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The card waits for the household: nothing is sent unasked.
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
expect(posts(fetchMock)).toHaveLength(0)
expect(await card()).toBeInTheDocument()
})
it.each([
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: label }))
// Applied before the server answers.
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
expect(posts(fetchMock)).toHaveLength(1)
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
// Rotation checks are read again once saved; none is left.
await waitFor(() =>
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
)
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
const next = await card()
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
await waitFor(() => expect(decisions).toHaveLength(2))
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
})
it('puts the check back with an ErrorBanner when the answer fails', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
const { decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The server closed the check, so the list read again has none.
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
})
it('reports Rotation checks it cannot load, with Retry', async () => {
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives; a notification tap there reads the checks again.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('opens Home with the card from a rotation_review push notification', async () => {
stubApi([SHAKSHUKA])
const origin = 'https://mealquest.test'
const { notice } = parsePushPayload(
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
origin,
)
const path = clickPath({ url: notice.url }, origin)
expect(path).toBe('/')
await renderHome(path)
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const { fetchMock, server } = stubApi([])
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
await renderHome('/')
const home = await screen.findByRole('heading', { name: 'Pizza night' })
await waitFor(() => expect(reads()).toBe(1))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
// Within the cached list's staleTime the Rotation check comes due and
// its notification is tapped while Home is open at /.
server.push(SHAKSHUKA)
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(win.navigate).not.toHaveBeenCalled()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(reads()).toBe(2)
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { Button } from '../ui/Button.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Photo } from '../ui/Photo.tsx'
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
import styles from './RotationCheck.module.css'
// CONFIRMATION is what Home says once the household has answered.
const CONFIRMATION: Record<Decision, string> = {
keep: 'Added to your Regulars',
drop: "Got it — we won't suggest it again",
not_made: 'Back in your Quest log',
}
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
// photo, "Last night: {dinner}", and the three answers.
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
const { dinner } = review
return (
<section aria-label="Keep it in rotation?" className={styles.card}>
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.body}>
<p className={styles.kicker}>Last night: {dinner.name}</p>
<h2 className={styles.question}>Keep it in rotation?</h2>
<div className={styles.actions}>
<Button disabled={disabled} onClick={() => onDecide('keep')}>
Keep it
</Button>
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
Not for us
</Button>
</div>
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
We didn't make it
</button>
</div>
</section>
)
}
// RotationCheck is the Rotation check on Home: the card for the oldest
// pending check (GET /api/v1/rotation-reviews), which stays until the
// household answers it — nothing is decided for them. An answer is applied
// at once: Home confirms it and shows the next pending check, if any, while
// the answer is saved; the answers are locked until it settles. A failed
// answer puts its check back with an ErrorBanner. A failed read shows an
// ErrorBanner, beside the checks last read: a card already shown stays
// until it is answered.
export function RotationCheck() {
const reviews = useRotationReviews()
const decide = useDecideRotationReview()
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
return (
<>
{answered !== undefined && (
<p role="status" className={styles.confirmation}>
{CONFIRMATION[answered.decision]}
</p>
)}
{reviews.isError && (
<ErrorBanner onRetry={() => void reviews.refetch()}>
Couldn't load the Rotation check. {reviews.error.message}
</ErrorBanner>
)}
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
{next !== undefined && (
<RotationCard
key={next.dinner.id}
review={next}
disabled={decide.isPending}
onDecide={(decision) => decide.mutate({ review: next, decision })}
/>
)}
</>
)
}
+4 -2
View File
@@ -7,6 +7,7 @@ import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
import { HomePage } from './HomePage.tsx'
afterEach(() => {
@@ -27,7 +28,7 @@ const UPDATES = {
type: 'plan_ready',
title: 'Your week is drafted',
body: 'Take a look at the Draft board.',
url: '/plan',
url: '/plan?week=2026-10-12',
created_at: minutesAgo(5),
},
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
@@ -52,6 +53,7 @@ function stubServer(responders: Record<string, Responder> = {}) {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
[`POST ${UPDATES_SEEN_PATH}`]: () => {
seen = true
return new Response(null, { status: 204 })
@@ -309,7 +311,7 @@ describe('Recent updates sheet', () => {
})
it.each([
[0, '/plan'],
[0, '/plan?week=2026-10-12'],
[1, '/discover'],
[2, '/plan/shopping?week=2026-10-05'],
])('row %i goes to %s and closes the sheet', async (i, to) => {
+14 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseUpdates } from './api.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { parseHome, parseUpdates } from './api.ts'
const origin = 'https://mealquest.example'
const where = 'GET /api/v1/updates'
@@ -37,3 +38,15 @@ describe('parseUpdates', () => {
)
})
})
describe('parseHome', () => {
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
)
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
'GET /api/v1/home: timezone undefined is not an IANA zone',
)
})
})
+35 -4
View File
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf, photoId } from '../dinners/api.ts'
import { weekLabel } from '../plan/week.ts'
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
import { appPath } from '../push/notification.ts'
export const HOME_PATH = '/api/v1/home'
@@ -27,11 +28,13 @@ export type Tonight = { id: string; name: string; photo_id: string | undefined;
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
// next day; tonight is null when today has no plan; sideQuestCount is how
// many Side quests wait in the deck; unread says the Recent updates hold
// something not yet seen.
export type Home = {
dateLabel: string
timezone: string
tonight: Tonight | null
sideQuestCount: number
shoppingList: ShoppingListStatus
@@ -77,6 +80,9 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
// parseHome checks a GET /api/v1/home body.
export function parseHome(where: string, body: unknown): Home {
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
}
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
throw new Error(`${where}: sideQuestCount is not a count`)
}
@@ -84,6 +90,7 @@ export function parseHome(where: string, body: unknown): Home {
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
return {
dateLabel: body.dateLabel,
timezone: body.timezone,
tonight: parseTonight(where, body.tonight),
sideQuestCount: body.sideQuestCount as number,
shoppingList: parseShoppingList(where, body.shoppingList),
@@ -91,13 +98,37 @@ export function parseHome(where: string, body: unknown): Home {
}
}
// useHome is the Home screen's summary (GET /api/v1/home).
// isZone says whether zone is an IANA zone this browser knows.
function isZone(zone: string): boolean {
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone })
return true
} catch {
return false
}
}
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
// again at the household's next local midnight, counted from each read, so
// a Home left open does not keep yesterday's date and dinner.
export function useHome() {
const { apiFetch } = useApi()
return useQuery({
const queryClient = useQueryClient()
const home = useQuery({
queryKey: HOME_KEY,
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
})
const zone = home.data?.timezone
const readAt = home.dataUpdatedAt
useEffect(() => {
if (zone === undefined) return
const timer = setTimeout(
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
untilLocalMidnight(zone, new Date()),
)
return () => clearTimeout(timer)
}, [queryClient, zone, readAt])
return home
}
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
+107
View File
@@ -0,0 +1,107 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { photoId } from '../dinners/api.ts'
import { isNotificationOpened } from '../push/notification.ts'
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
const DINNERS_KEY = ['dinners'] as const
// rotationReviewPath is where the household answers "Keep it in rotation?"
// for a tried dinner.
export function rotationReviewPath(dinnerId: string): string {
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
}
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
// for us") archives it so it is never suggested again, and not_made ("We
// didn't make it") returns it to the Quest log. Keep is the only way a tried
// dinner becomes a Regular; nothing decides one automatically.
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
export type Decision = (typeof DECISIONS)[number]
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
// planned on.
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
function parseReview(where: string, body: unknown): RotationReview {
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
const dinner = body.dinner
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: a Rotation check has no dinner`)
}
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
}
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
// pending Rotation checks, oldest plan date first.
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
return body.reviews.map((review) => parseReview(where, review))
}
// useRotationReviews is the pending Rotation checks (GET
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
// reads them afresh, and so does a notification tap that focuses Home
// already open (the service worker's NotificationOpened), so Home opened
// from a rotation_review notification shows the check it is about.
export function useRotationReviews() {
const { apiFetch } = useApi()
const query = useQuery({
queryKey: ROTATION_REVIEWS_KEY,
queryFn: async () =>
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
refetchOnMount: 'always',
})
const { refetch } = query
useEffect(() => {
// Without a service worker there are no notifications to tap.
if (!('serviceWorker' in navigator)) return
const container = navigator.serviceWorker
const onMessage = (event: MessageEvent<unknown>) => {
if (isNotificationOpened(event.data)) void refetch()
}
container.addEventListener('message', onMessage)
return () => container.removeEventListener('message', onMessage)
}, [refetch])
return query
}
// useDecideRotationReview answers "Keep it in rotation?" for a pending
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
// must be that dinner, decided that way.
//
// Home leaves the check out while its answer is pending (the mutation's
// variables say which), so the next one shows at once; a read in flight is
// stopped so it cannot bring the check back meanwhile. Once saved, the check
// leaves the cached list; once failed, it is shown again as the list has it.
// Either way the Rotation checks and the dinners lists (the Regulars and the
// Quest log the answer moved it to) are read again.
export function useDecideRotationReview() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
mutationFn: async ({ review, decision }) => {
const path = rotationReviewPath(review.dinner.id)
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
},
onSuccess: (_data, { review }) => {
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
)
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
},
})
}
+42 -4
View File
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
// the Plan page at entry, over queryClient, with shopping as the Supply run
// screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route
path="/plan"
element={
<>
<PlanPage />
<Searched />
</>
}
/>
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter>
@@ -190,6 +199,11 @@ function Opened() {
)
}
// Searched shows the Plan's search, the week it keeps in ?week=.
function Searched() {
return <p data-testid="search">{useLocation().search}</p>
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps.
function pushesKept(queryClient: QueryClient) {
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
stubServer({
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...lists,
})
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
// The week buttons keep ?week= on the week shown.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
})
it('says so when ?week= is not a Monday, and reads no plan', async () => {
const fetchMock = stubServer({ ...lists })
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
})
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
+17 -7
View File
@@ -1,13 +1,13 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Link, useSearchParams } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
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'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
@@ -40,6 +40,7 @@ import {
addDays,
dayOfMonth,
defaultWeek,
isMonday,
localNow,
weekdayKicker,
weekdayName,
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
// 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
// Regulars in a right panel on desktop (2d). It opens on next week once this
// 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
// week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
// 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.
export function PlanPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
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 })
let content
if (settings.isError) {
if (chosen !== null && !isMonday(chosen)) {
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
} else if (settings.isError) {
content = (
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
)
+54 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest'
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
import {
addDays,
dayAndDate,
dayOfMonth,
defaultWeek,
isMonday,
localNow,
untilLocalMidnight,
weekdayKicker,
weekdayName,
weekLabel,
weekStart,
} from './week.ts'
// 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' }
@@ -72,4 +84,45 @@ describe('week helpers', () => {
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
})
it('says whether a date is a real Monday', () => {
expect(isMonday('2026-10-05')).toBe(true)
expect(isMonday('2026-10-07')).toBe(false)
expect(isMonday('2026-02-30')).toBe(false)
expect(isMonday('next week')).toBe(false)
})
it('counts the time to the next midnight in the household zone, not UTC', () => {
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
// At midnight itself the next is a day away.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
// The same instant is 13:00 in Tokyo: eleven hours to go.
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
})
it('counts a day shortened by the spring clock change as 23 hours', () => {
const hour = 60 * 60 * 1000
// 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the
// next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour)
// 03:30 EDT, after the change: 20.5 hours to midnight.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour)
// At midnight starting the 23-hour day, the next midnight is 23 hours away.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour)
// Noon EST the day before: the change is after the coming midnight.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour)
})
it('counts a day lengthened by the autumn clock change as 25 hours', () => {
const hour = 60 * 60 * 1000
// Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00.
expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour)
})
it('starts the next day at the clock change when the clocks skip midnight', () => {
// Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z);
// at 23:00 -04 on September 5 the next day starts an hour later.
expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000)
})
})
+45
View File
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
return addDays(date, -sinceMonday)
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
export function isMonday(date: string): boolean {
try {
return weekStart(date) === date
} catch {
return false
}
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
@@ -115,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek
}
// wallClock is the household-local wall clock at instant t in IANA zone,
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
function wallClock(zone: string, t: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(t))
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
return wall + (((t % 1000) + 1000) % 1000)
}
// untilLocalMidnight is how many milliseconds after instant now the next
// local day begins in IANA zone. It finds the actual instant of the next
// day's midnight, so a day shortened or lengthened by a change of UTC offset
// is counted as it is. The candidates are that midnight at the zone's offset
// now and at the offset that candidate lands on; the earliest that falls on
// the next day is its start, which is the offset change itself when the
// clocks skip midnight.
export function untilLocalMidnight(zone: string, now: Date): number {
const t = now.getTime()
const tomorrow = addDays(localNow(zone, now).date, 1)
const midnight = midnightUTC(tomorrow).getTime()
const first = midnight - (wallClock(zone, t) - t)
const second = midnight - (wallClock(zone, first) - first)
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
return Math.min(...starts) - t
}
+14
View File
@@ -5,6 +5,7 @@ import {
GENERIC_NOTICE,
appPath,
clickPath,
isNotificationOpened,
openNotification,
parsePushPayload,
planClick,
@@ -178,6 +179,7 @@ describe('openNotification', () => {
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: vi.fn(),
} satisfies ClickWindow
}
@@ -193,6 +195,18 @@ describe('openNotification', () => {
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(win.postMessage).not.toHaveBeenCalled()
expect(all.openWindow).not.toHaveBeenCalled()
})
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/' }, all, origin)
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).not.toHaveBeenCalled()
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
expect(all.openWindow).not.toHaveBeenCalled()
})
+15 -1
View File
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
export type ClickWindow = AppWindow & {
focus(): Promise<unknown>
navigate(url: string): Promise<unknown>
postMessage(message: NotificationOpened): void
}
// NotificationOpened is the message a tap posts to an open window it focuses
// without navigating, because the window is already at url: nothing remounts
// there, so the screen reads afresh what the notification is about.
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
// isNotificationOpened reports whether a message from the service worker is
// NotificationOpened.
export function isNotificationOpened(message: unknown): message is NotificationOpened {
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
}
export type ClickClients<W extends ClickWindow> = {
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
// first, while the tap still allows it, then navigate; a window already at
// the url is told NotificationOpened instead.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
}
await plan.window.focus()
if (plan.navigate) await plan.window.navigate(plan.url)
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
}
+1 -10
View File
@@ -1,7 +1,7 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
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'
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+1
View File
@@ -66,6 +66,7 @@ export const GOOGLE_NOT_CONNECTED = {
// unread update.
export const HOME = {
dateLabel: 'Friday, Oct 2',
timezone: 'America/New_York',
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
sideQuestCount: 10,
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },