From fd087e0ba3f4c43aa7dc567faa2c328488e137ff Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 20:07:53 -0400 Subject: [PATCH] Feature 21: Home Dashboard (#181) Co-authored-by: dev_agent --- cmd/server/main.go | 8 +- docs/ARCHITECTURE.md | 67 +- internal/chat/chat_test.go | 4 +- internal/chat/messages.go | 5 +- internal/chat/messages_test.go | 12 +- internal/db/notifications.sql.go | 63 ++ internal/db/querier.go | 14 + internal/db/settings.sql.go | 17 + internal/home/export_test.go | 6 + internal/home/home.go | 253 ++++++++ internal/home/home_test.go | 598 ++++++++++++++++++ internal/household/settings.go | 16 + internal/notify/notify.go | 55 ++ internal/plan/plan_test.go | 4 +- internal/server/aisettings_test.go | 2 +- internal/server/chat_test.go | 4 +- internal/server/dinners_test.go | 2 +- internal/server/google_test.go | 2 +- internal/server/home.go | 85 +++ internal/server/home_test.go | 245 +++++++ internal/server/ideaactions_test.go | 2 +- internal/server/ideas_test.go | 2 +- internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans_test.go | 2 +- internal/server/push_test.go | 4 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 12 +- internal/server/server_test.go | 106 +++- internal/server/settings_test.go | 2 +- internal/server/suggestions_test.go | 2 +- internal/shopping/notify_test.go | 4 +- internal/suggest/suggest.go | 11 + migrations/queries/notifications.sql | 25 + migrations/queries/settings.sql | 8 + web/src/App.test.tsx | 36 +- web/src/App.tsx | 2 + web/src/home/HomePage.module.css | 193 ++++++ web/src/home/HomePage.test.tsx | 319 ++++++++++ web/src/home/HomePage.tsx | 158 +++++ web/src/home/RotationCheck.module.css | 79 +++ web/src/home/RotationCheck.test.tsx | 320 ++++++++++ web/src/home/RotationCheck.tsx | 79 +++ web/src/home/UpdatesSheet.module.css | 49 ++ web/src/home/UpdatesSheet.test.tsx | 383 +++++++++++ web/src/home/UpdatesSheet.tsx | 117 ++++ web/src/home/api.test.ts | 52 ++ web/src/home/api.ts | 230 +++++++ web/src/home/rotation.ts | 107 ++++ web/src/onboarding/NotificationsStep.test.tsx | 13 +- web/src/plan/PlanPage.test.tsx | 46 +- web/src/plan/PlanPage.tsx | 24 +- web/src/plan/week.test.ts | 55 +- web/src/plan/week.ts | 45 ++ web/src/push/notification.test.ts | 14 + web/src/push/notification.ts | 16 +- web/src/shopping/SupplyRunPage.tsx | 11 +- web/src/test/ApiProviders.tsx | 12 + 59 files changed, 3923 insertions(+), 87 deletions(-) create mode 100644 internal/home/export_test.go create mode 100644 internal/home/home.go create mode 100644 internal/home/home_test.go create mode 100644 internal/server/home.go create mode 100644 internal/server/home_test.go create mode 100644 web/src/home/HomePage.module.css create mode 100644 web/src/home/HomePage.test.tsx create mode 100644 web/src/home/HomePage.tsx create mode 100644 web/src/home/RotationCheck.module.css create mode 100644 web/src/home/RotationCheck.test.tsx create mode 100644 web/src/home/RotationCheck.tsx create mode 100644 web/src/home/UpdatesSheet.module.css create mode 100644 web/src/home/UpdatesSheet.test.tsx create mode 100644 web/src/home/UpdatesSheet.tsx create mode 100644 web/src/home/api.test.ts create mode 100644 web/src/home/api.ts create mode 100644 web/src/home/rotation.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index 230a4b1..0ca16d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -177,6 +178,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -221,7 +227,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19111e8..0472353 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -800,9 +800,11 @@ failed run is not retried. A claim that fails is not skipped: the pass is repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings change is recorded with the instant it was saved, and fire times up to that instant follow the old rule and later ones the new, however late the -scheduler applies it. At boot only each schedule's most recent fire -time is considered: it runs if it passed within 24 hours, and is otherwise -claimed without running (logged as missed) so it never runs later. DST +scheduler applies it. At boot, and whenever the scheduler wakes late, +every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is +processed oldest first, each running once. A most recent fire time that +passed longer ago is claimed without running (logged as missed) so it +never runs later; older ones are not looked at. DST follows `household.LocalTime`: a skipped time fires later by the gap, a repeated time fires once, at its first occurrence. @@ -870,14 +872,14 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where - 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, asked for + - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for by the API or the `plan_draft` schedule, 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:{YYYY-Www}` for a `scheduled` batch, the ISO week it @@ -1184,6 +1186,61 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where for any other `by`. A frozen list is read through `GET /api/v1/shopping/{weekStart}` with `status` `archived`. +## Home API (Feature 21) + +- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the + services that own each value, at "now" in the household's zone (never + UTC), and answers: + + ```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}, + "recentUpdates": {"unread": true} + } + ``` + + `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, 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 + notification was stored in `notification_outbox` after + `household_settings.updates_seen_at`, or at all while that is null + (`notify.StoredSince`); an event whose channels are all switched off is + never stored and never counts. +- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`) + answers the Recent updates sheet: the last 10 distinct events in + `notification_outbox`, newest first, one per event key whatever its + channels or delivery status (an event stored for push and Google Chat is + one update): + + ```json + {"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 + URL or secret; `url` is the same-origin path the event opens. +- `POST /api/v1/updates/seen` with `{"through": ""}` (CSRF + header required) records that the household has seen its updates + through the newest one it was shown, and answers 204. The last-seen + instant is `household_settings.updates_seen_at` — the one place it is + stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older + than the stored one is accepted and changes nothing, and an event + stored after `through` keeps `unread` true. It is not a settings edit: + `updated_by` and `updated_at` are left as they are and settings + subscribers are not told. 400 `invalid_request` for a body that is not + exactly that object, a `through` that is not RFC 3339, or one after + now. + ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go index 836dd0c..f3c0d15 100644 --- a/internal/chat/chat_test.go +++ b/internal/chat/chat_test.go @@ -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 { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index d06e16e..b29a2cd 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -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, } } diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go index 64c2261..6eaf87e 100644 --- a/internal/chat/messages_test.go +++ b/internal/chat/messages_test.go @@ -39,7 +39,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", @@ -49,7 +49,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", @@ -59,7 +59,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", @@ -69,7 +69,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", @@ -79,7 +79,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", @@ -89,7 +89,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", diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go index 41d92f2..94c1dd6 100644 --- a/internal/db/notifications.sql.go +++ b/internal/db/notifications.sql.go @@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi return i, err } +const hasNotificationSince = `-- name: HasNotificationSince :one +SELECT EXISTS ( + SELECT 1 FROM notification_outbox + WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT) +) AS found +` + +// Reports whether a notification was stored after since, or at all when +// since is NULL. +func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) { + row := q.db.QueryRowContext(ctx, hasNotificationSince, since) + var found bool + err := row.Scan(&found) + return found, err +} + const insertNotification = `-- name: InsertNotification :execrows INSERT INTO notification_outbox ( id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at @@ -166,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref return items, nil } +const listRecentNotifications = `-- name: ListRecentNotifications :many +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT ?1 +` + +type ListRecentNotificationsRow struct { + EventKey string + Payload string + CreatedAt string +} + +// Returns the last lim distinct events stored, newest first: one row per +// event key, the last stored for it whatever its channel, so an event +// stored for push and Google Chat is one row. The last, because a channel +// switched on later can store the same event key again, and +// HasNotificationSince counts that row: an event is as new as its newest +// row, so marking it seen also covers that row. +func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRecentNotificationsRow + for rows.Next() { + var i ListRecentNotificationsRow + if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markNotificationSent = `-- name: MarkNotificationSent :execrows UPDATE notification_outbox SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1 diff --git a/internal/db/querier.go b/internal/db/querier.go index 6cd1842..d1623b1 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -182,6 +182,9 @@ type Querier interface { // them only when a member edited them while it was grounding. Like // GroundDinner it clears the pasted recipe_url: the recipe replaces the link. GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) + // Reports whether a notification was stored after since, or at all when + // since is NULL. + HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) // Stores a pending notification for one channel, due at once. A row already // stored for the event key and channel, in any status, is left unchanged and // no row is inserted. @@ -212,6 +215,13 @@ type Querier interface { // Every stored subscription with its keys, for the push sender, oldest // first. ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) + // Returns the last lim distinct events stored, newest first: one row per + // event key, the last stored for it whatever its channel, so an event + // stored for push and Google Chat is one row. The last, because a channel + // switched on later can store the same event key again, and + // HasNotificationSince counts that row: an event is as new as its newest + // row, so marking it seen also covers that row. + ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) // Reads for the AI household context block (internal/ai/harness). // The most recent names the household turned down, newest first, as // ListPassedNames has them. @@ -254,6 +264,10 @@ type Querier interface { // Records list id sent to Google Chat at sent_to_chat_at, its send // confirmed, unless it is frozen into the Supply archive. MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) + // Moves updates_seen_at forward to seen_at; affects no row when it is + // already at or after seen_at, so it never moves back. It is not a settings + // edit: updated_by and updated_at are left as they are. + MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 04e9ec7..65f68dc 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows +UPDATE household_settings +SET updates_seen_at = CAST(?1 AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT)) +` + +// Moves updates_seen_at forward to seen_at; affects no row when it is +// already at or after seen_at, so it never moves back. It is not a settings +// edit: updated_by and updated_at are left as they are. +func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) { + result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const recordChatTest = `-- name: RecordChatTest :execrows UPDATE chat_settings SET last_test_at = ?, last_test_status = ?, updated_at = ? diff --git a/internal/home/export_test.go b/internal/home/export_test.go new file mode 100644 index 0000000..c3db366 --- /dev/null +++ b/internal/home/export_test.go @@ -0,0 +1,6 @@ +package home + +import "time" + +// SetClock makes now the clock s reads "now" and "tonight" on. +func SetClock(s *Service, now func() time.Time) { s.now = now } diff --git a/internal/home/home.go b/internal/home/home.go new file mode 100644 index 0000000..0532186 --- /dev/null +++ b/internal/home/home.go @@ -0,0 +1,253 @@ +// Package home reads the Home screen: tonight's dinner, the Side quests to +// swipe, the Supply run and whether there are recent updates. It owns no +// data: every value is read through the service that owns it, and "now" +// and "tonight" are in the household's zone, never UTC. +package home + +import ( + "context" + "errors" + "fmt" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +// Service reads the Home screen. +type Service struct { + settings *household.SettingsService + plans *plan.Service + suggest *suggest.Service + shopping *shopping.Service + notifier *notify.Service + // now is the clock "now" and "tonight" are read on; tests replace it. + now func() time.Time +} + +// NewService returns a service reading through the given services. +func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) { + switch { + case settings == nil: + return nil, errors.New("home: nil household settings service") + case plans == nil: + return nil, errors.New("home: nil plan service") + case suggestSvc == nil: + return nil, errors.New("home: nil suggestions service") + case shop == nil: + return nil, errors.New("home: nil shopping service") + case notifier == nil: + return nil, errors.New("home: nil notifier") + } + return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil +} + +// Home is the Home screen as the API shows it. +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"` + // SideQuestCount is how many Side quests are pending on the deck. + SideQuestCount int `json:"sideQuestCount"` + // ShoppingList is the Supply run of the upcoming week when it has one, + // else of the current week. + ShoppingList ShoppingStatus `json:"shoppingList"` + RecentUpdates RecentUpdates `json:"recentUpdates"` +} + +// Dinner is tonight's dinner. +type Dinner struct { + ID string `json:"id"` + Name string `json:"name"` + Photo *string `json:"photo"` + // Origin is regular or side_quest. + Origin string `json:"origin"` + MinutesTotal *int64 `json:"minutes_total"` +} + +// ShoppingStatus is whether a week's Supply run is ready. +type ShoppingStatus struct { + // WeekStart is the Monday of the week, YYYY-MM-DD. + WeekStart string `json:"weekStart"` + // Label is the week label, e.g. "Week 41 · Oct 5 – 11". + Label string `json:"label"` + // Ready is true when the week has a Supply run. + Ready bool `json:"ready"` + // SentToChatAt is when the Supply run was sent to Google Chat; nil + // until it is, and when there is no Supply run. + SentToChatAt *string `json:"sentToChatAt"` +} + +// RecentUpdates says whether there is something new for the bell. +type RecentUpdates struct { + // Unread is true when a notification was stored after the household + // last saw its updates (household_settings.updates_seen_at), or at all + // when it never has. + Unread bool `json:"unread"` +} + +// 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 { + return Home{}, fmt.Errorf("home: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return Home{}, fmt.Errorf("home: household settings: %w", err) + } + now := s.now().In(zone) + today := household.Today(now, zone) + thisWeek := household.WeekStart(today) + + tonight, err := s.tonight(ctx, thisWeek, today) + if err != nil { + return Home{}, err + } + sideQuests, err := s.suggest.PendingCount(ctx) + if err != nil { + return Home{}, fmt.Errorf("home: read deck: %w", err) + } + list, err := s.shoppingStatus(ctx, thisWeek) + if err != nil { + return Home{}, err + } + unread, err := s.unread(ctx, settings.UpdatesSeenAt) + if err != nil { + return Home{}, err + } + return Home{ + DateLabel: now.Format("Monday, Jan 2"), + Timezone: settings.Timezone, + Tonight: tonight, + SideQuestCount: sideQuests, + ShoppingList: list, + RecentUpdates: RecentUpdates{Unread: unread}, + }, nil +} + +// tonight returns the dinner planned on today in the plan of the week +// starting thisWeek, or nil when the week has no plan. +func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) { + p, err := s.plans.Get(ctx, thisWeek) + if errors.Is(err, plan.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("home: read plan: %w", err) + } + for _, d := range p.Days { + if d.Date == today.String() { + return &Dinner{ + ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo, + Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes, + }, nil + } + } + return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today) +} + +// shoppingStatus returns the status of the upcoming week's Supply run when +// it has one, else of the current week's, ready or not. +func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) { + y, m, d := thisWeek.Date() + nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC)) + for _, week := range []store.LocalDate{nextWeek, thisWeek} { + l, err := s.shopping.Get(ctx, week) + if errors.Is(err, shopping.ErrListNotFound) { + continue + } + if err != nil { + return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err) + } + return weekStatus(week, true, l.SentToChatAt) + } + return weekStatus(thisWeek, false, nil) +} + +func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) { + label, err := household.WeekLabel(week) + if err != nil { + return ShoppingStatus{}, fmt.Errorf("home: %w", err) + } + return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil +} + +// unread reports whether a notification was stored after seenAt, the +// stored updates_seen_at, or at all when it is nil. +func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) { + var since *time.Time + if seenAt != nil { + var ts store.Timestamp + if err := ts.Scan(*seenAt); err != nil { + return false, fmt.Errorf("home: household settings updates_seen_at: %w", err) + } + since = &ts.Time + } + unread, err := s.notifier.StoredSince(ctx, since) + if err != nil { + return false, fmt.Errorf("home: %w", err) + } + return unread, nil +} + +// RecentLimit is how many events the Recent updates sheet lists. +const RecentLimit = 10 + +// Update is one event in the Recent updates sheet. +type Update struct { + // Type is the event type, e.g. plan_ready. + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + // URL is the path-absolute, same-origin path a tap on the update opens. + URL string `json:"url"` + // CreatedAt is when the event was stored, UTC RFC 3339 with + // milliseconds; it is what POST /updates/seen takes back. + CreatedAt string `json:"created_at"` +} + +// Updates returns the last RecentLimit distinct notification events, newest +// first, whatever channels they went to. +func (s *Service) Updates(ctx context.Context) ([]Update, error) { + events, err := s.notifier.Recent(ctx, RecentLimit) + if err != nil { + return nil, fmt.Errorf("home: %w", err) + } + out := make([]Update, len(events)) + for i, ev := range events { + out[i] = Update{ + Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL, + CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(), + } + } + return out, nil +} + +// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now. +var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now") + +// MarkUpdatesSeen records that the household has seen its updates through +// through, the created_at of the newest update it was shown, so +// recentUpdates.unread is false until an event is stored after it. A time +// before the one already recorded changes nothing; one after now is +// ErrSeenInFuture, since it would hide events not yet stored. +func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error { + if through.After(s.now()) { + return ErrSeenInFuture + } + if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil { + return fmt.Errorf("home: %w", err) + } + return nil +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 0000000..a158d2b --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,598 @@ +package home_test + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// zone is the household's zone in these tests: not UTC, so a late local +// evening is already the next day in UTC. +const zone = "America/Chicago" + +// weekDates is the week of 2026-10-12, Monday first. +var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} + +type env struct { + svc *home.Service + st *store.Store + settings *household.SettingsService + notifier *notify.Service + loc *time.Location +} + +// newEnv returns a home service over a migrated temp database whose +// household is in zone, reading "now" from a clock set to at. +func newEnv(t *testing.T) *env { + t.Helper() + ctx := t.Context() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, zone); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + notifier := notifytest.New(t, st) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + + box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize))) + if err != nil { + t.Fatal(err) + } + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, box, gcal.Config{ + ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub, + SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no model in this test") + } + shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + settings := household.NewSettingsService(st) + svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier) + if err != nil { + t.Fatal(err) + } + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc} +} + +// at reads the Home screen with the clock at the household-local wall +// time "YYYY-MM-DD HH:MM". +func (e *env) at(t *testing.T, local string) home.Home { + t.Helper() + now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc) + if err != nil { + t.Fatal(err) + } + home.SetClock(e.svc, func() time.Time { return now }) + h, err := e.svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + return h +} + +// dinner stores a dinner and returns its id. +func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string { + t.Helper() + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin, + MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID +} + +// planWeek stores the plan of the week of 2026-10-12: the dinner named for +// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of +// unknown minutes. It returns the plan's id. +func (e *env) planWeek(t *testing.T) string { + t.Helper() + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for _, date := range weekDates { + id := e.dinner(t, "Dinner "+date, "regular", "regular", 30) + if date == "2026-10-14" { + id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date, + DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + return planID +} + +// Tonight's dinner and the date label follow the household's local +// midnight, not UTC's: late on Tuesday evening in Chicago it is already +// Wednesday in UTC, and Home still shows Tuesday's dinner. +func TestTonightSwitchesAtLocalMidnight(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + for _, tc := range []struct { + local, label, dinner string + }{ + // 23:59 CDT is 04:59 UTC the next day. + {"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"}, + {"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"}, + // 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan. + {"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"}, + } { + h := e.at(t, tc.local) + if h.DateLabel != tc.label { + t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label) + } + if h.Tonight == nil || h.Tonight.Name != tc.dinner { + t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner) + } + } + + // Late Sunday evening of the week before, the plan's week has not + // begun locally, though in UTC it has. + if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" { + t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel) + } +} + +// Tonight carries the dinner's id, name, photo, origin and minutes. +func TestTonightDinner(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + h := e.at(t, "2026-10-13 18:00") + if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil || + h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 { + t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight) + } + h = e.at(t, "2026-10-14 18:00") + if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil { + t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight) + } +} + +// With no plan, no suggestions, no Supply run and no notifications, Home +// says so: no dinner tonight, nothing to swipe, the current week's Supply +// run not ready and nothing unread. +func TestHomeEmpty(t *testing.T) { + t.Parallel() + e := newEnv(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", + }, + } + if h != want { + t.Errorf("home = %+v, want %+v", h, want) + } +} + +// The Side quest count is the pending cards of the deck: a decided +// suggestion is not counted. +func TestSideQuestCount(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Pho", "Ramen", "Tagine"} { + id := e.dinner(t, name, "suggested", "side_quest", 0) + if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 { + t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 { + t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount) + } +} + +// 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) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + planID := e.planWeek(t) + list := func(weekStart string, sent sql.NullString) { + t.Helper() + now := store.Now().String() + if _, err := e.st.Writer.ExecContext(ctx, + `INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`, + store.NewID(), weekStart, planID, sent, now, now); err != nil { + t.Fatal(err) + } + } + + list("2026-10-12", sql.NullString{}) + want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true} + if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want { + t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want) + } + + sent := "2026-10-17T13:00:00.000Z" + list("2026-10-19", sql.NullString{String: sent, Valid: true}) + h := e.at(t, "2026-10-16 09:00") + if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent { + t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent) + } + + // Late Sunday evening locally is Monday in UTC: the current week is + // still the week of 2026-10-12, so its upcoming week's list shows. + if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" { + t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart) + } + // On Monday the week of 2026-10-19 is current and has no upcoming + // list. + if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready { + t.Errorf("Monday: shoppingList = %+v, want the current week's", s) + } + // A week later neither the current nor the upcoming week has a list. + want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"} + if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want { + t.Errorf("no list: shoppingList = %+v, want %+v", s, want) + } +} + +// Recent updates are unread when a notification was stored after the +// household last saw its updates, or ever when it never has. +func TestRecentUpdatesUnread(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with no notifications") + } + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification never seen") + } + seen := func(at time.Time) { + t.Helper() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil { + t.Fatal(err) + } + } + seen(time.Now().Add(time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with every notification seen") + } + seen(time.Now().Add(-time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification stored after updates were seen") + } +} + +func TestNewServiceRequiresServices(t *testing.T) { + t.Parallel() + if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil { + t.Fatal("NewService accepted nil services") + } +} + +// store stores event key of type typ in the outbox at instant at, for each +// of channels, as notify stores an event. +func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } +} + +// Updates are the last ten distinct events, newest first: an event stored +// for push and Google Chat is listed once, whatever became of its delivery. +func TestUpdates(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if got == nil || len(got) != 0 { + t.Errorf("updates with no notifications = %#v, want an empty list", got) + } + + base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + for i := range 12 { + key := fmt.Sprintf("plan_ready:%02d", i) + e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat) + } + // A delivered and a failed row of one event are still one event. + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil { + t.Fatal(err) + } + + got, err = e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != home.RecentLimit { + t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got) + } + for i, u := range got { + n := 11 - i + key := fmt.Sprintf("plan_ready:%02d", n) + want := home.Update{ + Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, + CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(), + } + if u != want { + t.Errorf("updates[%d] = %+v, want %+v", i, u, want) + } + } +} + +// An event emitted to push and Google Chat is one update. +func TestUpdatesEmittedToTwoChannels(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil { + t.Fatal(err) + } + var rows int + if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 2 { + t.Fatalf("outbox rows = %d, want push and chat", rows) + } + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" { + t.Errorf("updates = %+v, want the one plan_ready event", got) + } +} + +// Marking updates seen through the newest clears unread until a newer event +// is stored; marking them seen through an older time does not move the +// mark back, and a time after now is refused. +func TestMarkUpdatesSeen(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + unread := func() bool { + t.Helper() + return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread + } + first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC) + e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush) + if !unread() { + t.Fatal("not unread with an event never seen") + } + // The settings were last edited well before: marking updates seen must + // keep that time and tell no settings subscriber. + edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil { + t.Fatal(err) + } + var notified int + unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ }) + defer unsubscribe() + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newest event seen") + } + + second := first.Add(time.Millisecond) + e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat) + if !unread() { + t.Error("not unread with an event stored after the mark") + } + // Seen again through the older event, as a sheet opened before the + // newer one arrived would: nothing moves, so it stays unread. + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if !unread() { + t.Error("an older mark hid the newer event") + } + if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newer event seen") + } + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + settings, err := e.settings.Get(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want { + t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want) + } + if settings.UpdatedBy != nil { + t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy) + } + if settings.UpdatedAt != edited { + t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited) + } + if notified != 0 { + t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified) + } + + // The clock reads 17:00 UTC. + if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) { + t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err) + } +} + +// An event stored again for a channel switched on later is as new as that +// row: the update shows its time, and marking it seen clears unread. +func TestUpdateStoredAgainForAnotherChannel(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) + later := first.Add(30 * time.Minute) + e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush) + e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat) + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want { + t.Fatalf("updates = %+v, want the one event at %s", got, want) + } + e.at(t, "2026-10-13 12:00") + if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil { + t.Fatal(err) + } + if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread { + t.Error("unread after the event was marked seen through its listed time") + } +} diff --git a/internal/household/settings.go b/internal/household/settings.go index 88b8586..f7fc55e 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "sync" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat return settings, nil } +// MarkUpdatesSeen records that the household has seen its Recent updates +// through seenAt: updates_seen_at moves forward to seenAt, and is left as it +// is when it is already at or after it. It is not a settings edit, so +// updated_by and updated_at are kept and subscribers are not told. +func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error { + if seenAt.IsZero() { + return errors.New("household: mark updates seen: zero time") + } + _, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String()) + if err != nil { + return fmt.Errorf("household: mark updates seen: %w", err) + } + return nil +} + // applyPatch applies a validated patch to the household settings row in q's // transaction, recording updatedBy, and returns the new row. func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 018706c..38dc0fa 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -190,6 +190,61 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error { }) } +// StoredSince reports whether a notification was stored after since, or +// at all when since is nil. An event counts once it is stored for any +// channel, whatever became of its delivery; one whose preference switches +// every channel off is never stored and never counts. +func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) { + var after sql.NullString + if since != nil { + after = sql.NullString{String: timestamp(*since), Valid: true} + } + found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after) + if err != nil { + return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err) + } + return found, nil +} + +// Stored is one event as it was stored, whatever channels it went to. +type Stored struct { + Type EventType + Title string + Body string + // URL is the path-absolute, same-origin path the event opens. + URL string + // CreatedAt is when the event was last stored, for any channel: the + // instant StoredSince must be given for it not to count. + CreatedAt time.Time +} + +// Recent returns the last n distinct events stored, newest first. An event +// stored for more than one channel is one event, as new as its newest row; +// one whose preference switches every channel off is never stored and +// never listed. +func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) { + if n <= 0 { + return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n) + } + rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n)) + if err != nil { + return nil, fmt.Errorf("notify: list recent events: %w", err) + } + out := make([]Stored, 0, len(rows)) + for _, r := range rows { + var p Payload + if err := json.Unmarshal([]byte(r.Payload), &p); err != nil { + return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err) + } + created, err := parseTimestamp(r.CreatedAt) + if err != nil { + return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err) + } + out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created}) + } + return out, nil +} + // emitTx stores ev in tx for each channel that channels chooses from the // preference of ev's type. func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error { diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 89decf6..4b1f557 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -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) } } diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index e5b4a1f..2679a39 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go index d1b6c7b..a55cfef 100644 --- a/internal/server/chat_test.go +++ b/internal/server/chat_test.go @@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string { func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) { func TestNewRouterRequiresChat(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Chat service") } } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index f0ce5c7..cf7e40d 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -98,7 +98,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index ccdc4e0..c09c1ae 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/home.go b/internal/server/home.go new file mode 100644 index 0000000..92377fe --- /dev/null +++ b/internal/server/home.go @@ -0,0 +1,85 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" +) + +// getHome answers GET /home with the Home screen at the household's local +// "now". +func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read home", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, h) + } +} + +// updatesBody is the GET /updates answer. +type updatesBody struct { + Updates []home.Update `json:"updates"` +} + +// getUpdates answers GET /updates with the Recent updates: the last +// home.RecentLimit distinct notification events, newest first. +func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + updates, err := svc.Updates(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read recent updates", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates}) + } +} + +// maxUpdatesSeenBody bounds a POST /updates/seen request body. +const maxUpdatesSeenBody = 1 << 10 + +// updatesSeenRequest is the POST /updates/seen body: through is the +// created_at of the newest update the household was shown. +type updatesSeenRequest struct { + Through *string `json:"through"` +} + +// postUpdatesSeen answers POST /updates/seen by recording that the +// household has seen its updates through the body's through, and answers +// 204. A through before the one already recorded changes nothing. +func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Through == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required") + return + } + through, err := time.Parse(time.RFC3339Nano, *req.Through) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp") + return + } + err = svc.MarkUpdatesSeen(r.Context(), through) + if errors.Is(err, home.ErrSeenInFuture) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now") + return + } + if err != nil { + logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/server/home_test.go b/internal/server/home_test.go new file mode 100644 index 0000000..513ddb2 --- /dev/null +++ b/internal/server/home_test.go @@ -0,0 +1,245 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// GET /home answers the Home screen in its JSON shape; newTestHome's +// household (America/New_York) has no plan, no suggestions, no Supply run +// and no notifications. +func TestGetHome(t *testing.T) { + t.Parallel() + h := newTestRouter(t, "dev") + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + // want is the answer at instant now; the request is read between two + // instants, so it matches the answer at one of them. + want := func(now time.Time) string { + week := household.WeekStart(household.Today(now, loc)) + label, err := household.WeekLabel(week) + if err != nil { + t.Fatal(err) + } + 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}, + "recentUpdates": map[string]any{"unread": false}, + }) + if err != nil { + t.Fatal(err) + } + return string(body) + } + before := time.Now() + rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil)) + after := time.Now() + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body) + } + var got any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + canonical, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if string(canonical) != want(before) && string(canonical) != want(after) { + t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after)) + } +} + +func TestGetHomeNeedsSession(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code) + } +} + +// homeRouter returns a router whose Home reads st, and st. +func homeRouter(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + svc := newTestHomeOn(t, st) + return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st +} + +// storeEvent stores event key in st's outbox at instant at for each of +// channels, as notify stores an event, and returns at as stored. +func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } + return ts +} + +// unread reads recentUpdates.unread from GET /home. +func unread(t *testing.T, h http.Handler) bool { + t.Helper() + rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body) + } + updates, ok := body["recentUpdates"].(map[string]any) + if !ok { + t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body) + } + return updates["unread"] == true +} + +// markSeen posts body to /updates/seen and returns the answer. +func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))) + return rec +} + +// GET /updates lists the last distinct events newest first, one per event +// whatever its channels, in their JSON shape. +func TestGetUpdates(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` { + t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body) + } + + now := time.Now() + older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush) + newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat) + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + want := `{"updates":[` + + `{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` + + `{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want) + } +} + +// Unread is true while an event newer than the last seen one exists, +// clears once POST /updates/seen marks the newest seen, and returns when a +// newer event is stored; marking an older one seen again moves nothing. +func TestUpdatesSeen(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + now := time.Now() + + if unread(t, h) { + t.Error("unread with no events") + } + first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat) + if !unread(t, h) { + t.Error("not unread with an event never seen") + } + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newest event was marked seen") + } + + second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush) + if !unread(t, h) { + t.Error("not unread with an event newer than the last seen") + } + // Already seen: marking the older event seen again is accepted and + // changes nothing. + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body) + } + if !unread(t, h) { + t.Error("marking an older event seen hid a newer one") + } + if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newer event was marked seen") + } +} + +// A malformed POST /updates/seen is 400 and records nothing. +func TestUpdatesSeenRejectsMalformed(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush) + + for _, body := range []string{ + ``, + `[]`, + `{}`, + `{"through":null}`, + `{"through":42}`, + `{"through":"yesterday"}`, + `{"through":"2026-10-13 12:00:00"}`, + `{"through":"2026-10-13T12:00:00.000Z","extra":true}`, + `{"through":"2026-10-13T12:00:00.000Z"} {}`, + `{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`, + } { + rec := markSeen(t, h, body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) { + t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body) + } + } + if !unread(t, h) { + t.Error("a malformed POST /updates/seen marked updates seen") + } +} + +// Both endpoints need a session, and POST /updates/seen the CSRF token. +func TestUpdatesNeedSessionAndCSRF(t *testing.T) { + t.Parallel() + h, _ := homeRouter(t) + body := `{"through":"2026-10-01T12:00:00.000Z"}` + for _, req := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)), + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code) + } + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code) + } +} diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go index 535a22b..5bc40bc 100644 --- a/internal/server/ideaactions_test.go +++ b/internal/server/ideaactions_test.go @@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index 73b2d1c..35c77cd 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/login_test.go b/internal/server/login_test.go index ceb4c6f..3bd7234 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index 77490c7..d527d3b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 20bc97d..de89969 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index c73341f..5da305e 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/push_test.go b/internal/server/push_test.go index cf1779d..5ffdcff 100644 --- a/internal/server/push_test.go +++ b/internal/server/push_test.go @@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) func newPushRouter(t *testing.T, svc *push.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil push service") } } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 028887d..caf61fd 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index dcff31f..3b0352e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -66,12 +67,13 @@ type RouteMounter interface { // /api/v1/settings/notifications/test-push; ideasSvc serves POST // /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with // dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with -// planSvc too, /cook-tonight; runner serves +// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET +// /api/v1/updates and POST /api/v1/updates/seen; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -105,6 +107,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if ideasSvc == nil { return nil, errors.New("server: nil ideas service") } + if homeSvc == nil { + return nil, errors.New("server: nil home service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -137,6 +142,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a sub.NotFound(notFound) } apiV1.Get("/me", me(logger)) + apiV1.Get("/home", getHome(logger, homeSvc)) + apiV1.Get("/updates", getUpdates(logger, homeSvc)) + apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7cb8d27..402b925 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -22,8 +22,10 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -50,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler { } func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler { + t.Helper() + return newTestRouterWithHome(t, version, settings, runner, newTestHome(t)) +} + +// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc. +func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) assets := fstest.MapFS{ "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -257,6 +265,71 @@ func newTestShopping(t *testing.T) *shopping.Service { return svc } +// newTestHome returns a home service whose services all share one +// migrated temp database with zone America/New_York, with an unstarted job +// runner, no model, and Google and Google Chat endpoints that refuse every +// connection. One database keeps the routers that never ask for Home cheap. +func newTestHome(t *testing.T) *home.Service { + t.Helper() + return newTestHomeOn(t, newTestStore(t)) +} + +// newTestHomeOn returns a home service over st, a migrated database, which +// it seeds with zone America/New_York and the seed notification preferences. +func newTestHomeOn(t *testing.T, st *store.Store) *home.Service { + t.Helper() + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + notifier := notifytest.New(t, st) + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"}, + RevokeURL: "http://127.0.0.1:1/revoke", + CalendarAPI: "http://127.0.0.1:1/calendar/v3", + }) + if err != nil { + t.Fatal(err) + } + planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{}) + if err != nil { + t.Fatal(err) + } + shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier) + if err != nil { + t.Fatal(err) + } + return svc +} + // untitled is a shopping.Titler with no model: every Supply run it is // asked to title stays untitled. func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { @@ -317,7 +390,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -326,7 +399,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -335,7 +408,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -344,7 +417,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -353,7 +426,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -362,7 +435,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -371,7 +444,7 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -380,7 +453,7 @@ func TestNewRouterRequiresShopping(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } @@ -389,16 +462,25 @@ func TestNewRouterRequiresIdeas(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil ideas service") } } +func TestNewRouterRequiresHome(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil home service") + } +} + func TestNewRouterRequiresGoogle(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -407,7 +489,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 84bd11f..bb8f67b 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index aaba602..aeac339 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go index eb23d45..873f3a1 100644 --- a/internal/shopping/notify_test.go +++ b/internal/shopping/notify_test.go @@ -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) } } diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 6b3c1f5..2aa6c81 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -366,6 +366,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. diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql index c9deb42..d952567 100644 --- a/migrations/queries/notifications.sql +++ b/migrations/queries/notifications.sql @@ -66,3 +66,28 @@ SELECT * FROM notification_prefs; UPDATE notification_prefs SET push = ?, chat = ?, updated_at = ? WHERE event = ?; + +-- name: HasNotificationSince :one +-- Reports whether a notification was stored after since, or at all when +-- since is NULL. +SELECT EXISTS ( + SELECT 1 FROM notification_outbox + WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT) +) AS found; + +-- name: ListRecentNotifications :many +-- Returns the last lim distinct events stored, newest first: one row per +-- event key, the last stored for it whatever its channel, so an event +-- stored for push and Google Chat is one row. The last, because a channel +-- switched on later can store the same event key again, and +-- HasNotificationSince counts that row: an event is as new as its newest +-- row, so marking it seen also covers that row. +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT sqlc.arg(lim); diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index f40a0d7..eb9ec9d 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -154,3 +154,11 @@ RETURNING *; UPDATE chat_settings SET last_test_at = ?, last_test_status = ?, updated_at = ? WHERE id = 1 AND webhook_url_ct = ?; + +-- name: MarkUpdatesSeen :execrows +-- Moves updates_seen_at forward to seen_at; affects no row when it is +-- already at or after seen_at, so it never moves back. It is not a settings +-- edit: updated_by and updated_at are left as they are. +UPDATE household_settings +SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT)); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index af91d47..e36d16f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' import { dinnerBody } from './test/dinners.ts' import { defaultWeek } from './plan/week.ts' @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { + '/': true, '/onboarding': true, '/plan': true, '/plan/shopping': true, @@ -85,6 +86,16 @@ describe('App routes', () => { }, ) + it('/ renders the Home screen inside the shell', async () => { + mockViewport(390) + 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) + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }) + it('/settings renders the settings home inside the shell', async () => { mockViewport(390) stubFetchByPath({ @@ -262,12 +273,19 @@ 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)) + 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') screen.getByRole('button', { name: 'Retry' }).click() - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() }) }) @@ -282,9 +300,9 @@ 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) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) }) const { unmount } = renderAt('/') - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() unmount() } @@ -295,14 +313,16 @@ describe('App offline bootstrap', () => { }) // A test that emits an offline event leaves TanStack Query's online - // manager, shared by every test, offline until an online event. + // manager, shared by every test, offline until an online event. The + // manager listens for it only while a query client is mounted, so the + // event goes out before cleanup unmounts the app. afterEach(() => { - cleanup() window.dispatchEvent(new Event('online')) + cleanup() }) it.each([ - ['/', 'Home'], + ['/', "Tonight's quest"], ['/settings/notifications', 'Notifications'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() diff --git a/web/src/App.tsx b/web/src/App.tsx index deea5b5..54ad6d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' +import { HomePage } from './home/HomePage.tsx' import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' @@ -45,6 +46,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/': , '/plan': , '/plan/shopping': , '/plan/shopping/archive': , diff --git a/web/src/home/HomePage.module.css b/web/src/home/HomePage.module.css new file mode 100644 index 0000000..9cd4b56 --- /dev/null +++ b/web/src/home/HomePage.module.css @@ -0,0 +1,193 @@ +/* Home, handoff mock 1g. */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2px; +} + +.date { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.1; +} + +/* The bell's 8 px grape unread dot sits on the 40 px face, which is + centred in the IconButton's 44 px hit box. */ +.bell { + position: relative; +} + +.bell .bellIcon { + width: 19px; + height: 19px; +} + +.dot { + position: absolute; + top: 10px; + right: 11px; + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-accent); +} + +.card { + display: flex; + flex-direction: column; + border-radius: 32px; + background: var(--color-surface); + overflow: hidden; +} + +.photo { + height: 150px; +} + +.cardFoot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + padding: 2px 18px 16px; +} + +.cardText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.1; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */ +.recipe { + flex: none; + min-height: 44px; + padding: 0 16px; + font-size: 13px; + text-decoration: none; +} + +.tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.tile { + box-sizing: border-box; + height: 104px; + padding: 14px; + display: flex; + flex-direction: column; + justify-content: space-between; + border-radius: 26px; + text-decoration: none; +} + +.quick { + background: var(--color-accent); + color: var(--on-accent); +} + +.pantry { + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); +} + +.tileLabel { + font-family: var(--font-heading); + font-size: 16px; + line-height: 1.1; +} + +.statuses { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.status { + box-sizing: border-box; + min-height: 44px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 20px; + background: var(--color-neutral-100); + color: var(--color-text); + text-decoration: none; +} + +.status .statusIcon { + width: 18px; + height: 18px; + color: var(--color-accent-700); +} + +.statusText { + flex: 1; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +.tile:focus-visible, +.status:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.skeletonDate { + width: 96px; + height: 14px; + margin-bottom: 2px; +} + +.skeletonCard { + height: 238px; + border-radius: 32px; +} + +.skeletonStatus { + height: 44px; + border-radius: 20px; +} diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx new file mode 100644 index 0000000..09c4e34 --- /dev/null +++ b/web/src/home/HomePage.test.tsx @@ -0,0 +1,319 @@ +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, 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. +const NOTHING_YET = { + ...HOME, + tonight: null, + sideQuestCount: 0, + shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null }, + recentUpdates: { unread: false }, +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +// Where shows the route a link went to. +function Where() { + const location = useLocation() + return

{location.pathname + location.search}

+} + +function renderHome(body: unknown) { + stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) }) + return renderRouter() +} + +function renderRouter() { + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '*', element: }, + ], + { initialEntries: ['/'] }, + ) + render( + + + , + ) +} + +describe('HomePage', () => { + it('renders mock 1g from GET /api/v1/home', async () => { + renderHome(HOME) + + expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + + const tonight = screen.getByRole('region', { name: 'Tonight' }) + expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument() + expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1') + + expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick') + expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry') + expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover') + expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute( + 'href', + '/plan/shopping?week=2026-10-05', + ) + 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) + + const bell = await screen.findByRole('button', { name: 'Recent updates, unread' }) + const dot = within(bell).getByTestId('unread-dot') + expect(declaredStyle(dot).background).toBe('var(--color-accent)') + expect(declaredStyle(dot).width).toBe('8px') + expectMinHitArea(bell) + }) + + it('shows the bell without a dot when every update is seen', async () => { + renderHome(NOTHING_YET) + + const bell = await screen.findByRole('button', { name: 'Recent updates' }) + expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('labels a Side quest tonight "Side quest"', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } }) + expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument() + }) + + it('shows only the origin when tonight has no minutes', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } }) + const tonight = await screen.findByRole('region', { name: 'Tonight' }) + expect(within(tonight).getByText('Regular')).toBeInTheDocument() + // No photo: the striped placeholder, named for the dinner. + expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo') + }) + + it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => { + renderHome(NOTHING_YET) + + expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + // The tiles stay. + expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Plan this week' })) + expect(await screen.findByTestId('where')).toHaveTextContent('/plan') + }) + + it('hides the Side quests row at zero and keeps a ready Supply run', async () => { + renderHome({ ...HOME, sideQuestCount: 0 }) + + expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument() + }) + + it('hides the Supply run row when no list is ready', async () => { + renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList }) + + expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + }) + + it('counts a single Side quest in the singular', async () => { + renderHome({ ...HOME, sideQuestCount: 1 }) + expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument() + }) + + it('shows a week spanning two months in full', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } }) + expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument() + }) + + it.each([ + ['Recipe', '/dinner/d1'], + ['Quick dinner', '/quick'], + ['Cook from what I have', '/cook-from-pantry'], + ['10 new side quests to swipe', '/discover'], + ['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'], + ])('%s goes to %s', async (name, to) => { + renderHome(HOME) + + fireEvent.click(await screen.findByRole('link', { name })) + expect(await screen.findByTestId('where')).toHaveTextContent(to) + }) + + it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => { + renderHome(HOME) + + const tonight = await screen.findByRole('region', { name: 'Tonight' }) + expect(declaredStyle(tonight).borderRadius).toBe('32px') + expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px') + expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px') + + const quick = screen.getByRole('link', { name: 'Quick dinner' }) + expect(declaredStyle(quick).height).toBe('104px') + expect(declaredStyle(quick).background).toBe('var(--color-accent)') + expect(declaredStyle(quick).color).toBe('var(--on-accent)') + const pantry = screen.getByRole('link', { name: 'Cook from what I have' }) + expect(declaredStyle(pantry).height).toBe('104px') + expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)') + + expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px') + + expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' })) + // The tiles are 104 px tall (above); the status rows fill the width at 44 px or more. + for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) { + expect(declaredStyle(row).minHeight).toBe('44px') + } + }) + + it('shows skeletons while the home loads', async () => { + let answer!: (response: Response) => void + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise((resolve) => (answer = resolve))), + ) + renderRouter() + + expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument() + expect(screen.getByTestId('skeleton-date')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2) + expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + + answer(jsonResponse(200, HOME)) + expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument() + }) + + it('reports a failure with Retry, and no made-up content', async () => { + 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") + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('reports a home it cannot read rather than guessing', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } }) + expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest') + }) + + it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + `tonight's minutes_total ${minutes} is not a positive whole number`, + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByText(/min$/)).not.toBeInTheDocument() + }) + + it('reports tonight without a minutes_total rather than showing it as unknown', async () => { + const { minutes_total: _, ...tonight } = HOME.tonight + renderHome({ ...HOME, tonight }) + + expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByText('Regular')).not.toBeInTheDocument() + }) + + it.each([ + ['a missing', undefined], + ['an empty', ''], + ])('reports a ready Supply run with %s label rather than showing it', async (_, label) => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + + it('reports a ready Supply run whose label is not its week rather than showing it', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05', + ) + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + }) + + it.each([ + ['a missing', undefined], + ['an empty', ''], + ])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => { + renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + + it('reports an unready Supply run whose label is not its week rather than showing the home', async () => { + renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28', + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + + it('reports a Supply run whose weekStart is not a date rather than showing it', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date') + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) +}) diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx new file mode 100644 index 0000000..2ceead1 --- /dev/null +++ b/web/src/home/HomePage.tsx @@ -0,0 +1,158 @@ +import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react' +import { useState } from 'react' +import { Link, useNavigate } from 'react-router' +import { duration } from '../plan/PlanPage.tsx' +import { supplyRunScreen } from '../shopping/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +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 = { regular: 'Regular', side_quest: 'Side quest' } + +// meta is the Tonight card's "Regular · 40 min" line, the time only when +// the recipe says it. +function meta({ origin, minutes_total }: Tonight): string { + return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}` +} + +// TonightCard is the dinner planned for today: its washed photo, name and +// meta line, and the dark Recipe pill to its recipe. +function TonightCard({ tonight }: { tonight: Tonight }) { + return ( +
+ +
+
+

{tonight.name}

+ {meta(tonight)} +
+ + Recipe + +
+
+ ) +} + +// NoPlan stands in for the Tonight card when today has no plan. +function NoPlan() { + const navigate = useNavigate() + return ( +
+ void navigate('/plan') }}> + Draft this week's dinners and tonight's quest shows up here. + +
+ ) +} + +// StatusRow is one neutral row under the tiles: a grape-700 icon, its line +// and a chevron, linking to the screen it is about. +function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) { + return ( +
  • + + + {children} + + +
  • + ) +} + +// StatusRows are the rows that apply: Side quests waiting in the deck, and +// a Supply run ready. With neither, there are no rows. +function StatusRows({ home }: { home: Home }) { + const count = home.sideQuestCount + const list = home.shoppingList + if (count === 0 && !list.ready) return null + return ( +
      + {count > 0 && ( + + {`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`} + + )} + {list.ready && ( + + {`Shopping list ready · ${list.dates}`} + + )} +
    + ) +} + +// Tiles are the two shortcuts, Quick dinner and Cook from what I have. +function Tiles() { + return ( +
    + + + Quick dinner + + + + Cook from what I have + +
    + ) +} + +// 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. 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. +export function HomePage() { + const home = useHome() + const data = home.data + const [updatesOpen, setUpdatesOpen] = useState(false) + return ( +
    +
    +
    + {data !== undefined ? ( +

    {data.dateLabel}

    + ) : ( + home.isPending && + )} +

    Tonight's quest

    +
    + setUpdatesOpen(true)} + > + + {data?.unread && } + +
    + {home.isPending && } + {home.isError && ( + void home.refetch()}>Couldn't load tonight's quest. {home.error.message} + )} + {data !== undefined && (data.tonight === null ? : )} + + + {home.isPending && ( + + )} + {data !== undefined && } + setUpdatesOpen(false)} /> +
    + ) +} diff --git a/web/src/home/RotationCheck.module.css b/web/src/home/RotationCheck.module.css new file mode 100644 index 0000000..710d0bc --- /dev/null +++ b/web/src/home/RotationCheck.module.css @@ -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; +} diff --git a/web/src/home/RotationCheck.test.tsx b/web/src/home/RotationCheck.test.tsx new file mode 100644 index 0000000..875e9cd --- /dev/null +++ b/web/src/home/RotationCheck.test.tsx @@ -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((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((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: }], { initialEntries: [path] }) + render( + + + + + , + ) +} + +const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' }) +const posts = (fetchMock: ReturnType['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((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((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) + }) +}) diff --git a/web/src/home/RotationCheck.tsx b/web/src/home/RotationCheck.tsx new file mode 100644 index 0000000..e2b0aef --- /dev/null +++ b/web/src/home/RotationCheck.tsx @@ -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 = { + 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 ( +
    + +
    +

    Last night: {dinner.name}

    +

    Keep it in rotation?

    +
    + + +
    + +
    +
    + ) +} + +// 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 && ( +

    + {CONFIRMATION[answered.decision]} +

    + )} + {reviews.isError && ( + void reviews.refetch()}> + Couldn't load the Rotation check. {reviews.error.message} + + )} + {decide.isError && Couldn't save that answer. {decide.error.message}} + {next !== undefined && ( + decide.mutate({ review: next, decision })} + /> + )} + + ) +} diff --git a/web/src/home/UpdatesSheet.module.css b/web/src/home/UpdatesSheet.module.css new file mode 100644 index 0000000..14c7eb0 --- /dev/null +++ b/web/src/home/UpdatesSheet.module.css @@ -0,0 +1,49 @@ +/* The Recent updates sheet the Home bell opens. */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.icon { + color: var(--color-accent-700); +} + +.time { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); + white-space: nowrap; +} + +.skeletonRow { + height: 56px; + border-radius: var(--radius-md); +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/home/UpdatesSheet.test.tsx b/web/src/home/UpdatesSheet.test.tsx new file mode 100644 index 0000000..eb7bc3a --- /dev/null +++ b/web/src/home/UpdatesSheet.test.tsx @@ -0,0 +1,383 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } 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 { 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(() => { + vi.unstubAllGlobals() +}) + +// minutesAgo is the stored form of the instant n minutes before now. +function minutesAgo(n: number): string { + return new Date(Date.now() - n * 60_000).toISOString() +} + +// UPDATES are a GET /api/v1/updates body, newest first: the week drafted +// five minutes ago, new Side quests three hours ago and the Supply run two +// days ago. +const UPDATES = { + updates: [ + { + type: 'plan_ready', + title: 'Your week is drafted', + body: 'Take a look at the Draft board.', + 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) }, + { + type: 'list_ready', + title: 'Supply run ready', + body: 'Week 41 · Oct 5 – 11', + url: '/plan/shopping?week=2026-10-05', + created_at: minutesAgo(2 * 24 * 60), + }, + ], +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. Home is unread until the updates are marked +// seen, as the server answers. +function stubServer(responders: Record = {}) { + let seen = false + const all: Record = { + '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 }) + }, + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// posts lists the bodies of the POST /updates/seen requests sent, each of +// which must carry the CSRF token. +function posts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH) + .map(([, init]) => { + expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + return String(init?.body) + }) +} + +function homeReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET') + .length +} + +function updatesReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter( + ([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET', + ).length +} + +// Where shows the route a row went to. +function Where() { + const location = useLocation() + return

    {location.pathname + location.search}

    +} + +// renderHome loads /me (for the CSRF token the POST needs) and renders Home, +// answering its query client. +async function renderHome() { + const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '*', element: }, + ], + { initialEntries: ['/'] }, + ) + const queryClient = createQueryClient() + render( + + + + + , + ) + return queryClient +} + +// openSheet taps the unread bell and answers the open sheet. +async function openSheet() { + fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' })) + return screen.getByRole('dialog', { name: 'Recent updates' }) +} + +async function rows(sheet: HTMLElement) { + return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem') +} + +describe('Recent updates sheet', () => { + it('lists the updates newest first with how long ago each came', async () => { + stubServer() + await renderHome() + const listed = await rows(await openSheet()) + + expect(listed).toHaveLength(3) + expect(listed[0]).toHaveTextContent('Your week is drafted') + expect(listed[0]).toHaveTextContent('Take a look at the Draft board.') + expect(listed[0]).toHaveTextContent('5 min ago') + expect(listed[1]).toHaveTextContent('5 new Side quests') + expect(listed[1]).toHaveTextContent('3 hr ago') + expect(listed[2]).toHaveTextContent('Supply run ready') + expect(listed[2]).toHaveTextContent('2 days ago') + expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at) + }) + + it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => { + const fetchMock = stubServer() + await renderHome() + expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() + await openSheet() + + await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })])) + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + // Home is read again once the mark is saved, and the server says + // nothing is unread: the dot does not come back. + await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument() + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => { + // The server holds the stored events, newest first, and says Home is + // unread while the newest is after the time marked seen. + const stored = [...UPDATES.updates] + let seenThrough = '' + const fetchMock = stubServer({ + [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }), + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }), + [`POST ${UPDATES_SEEN_PATH}`]: (init) => { + seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through + return new Response(null, { status: 204 }) + }, + }) + const queryClient = await renderHome() + await rows(await openSheet()) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + + // A new event is stored a moment later, well within the query client's + // staleTime, and Home is read again, as on coming back to it. + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + await queryClient.invalidateQueries({ queryKey: HOME_KEY }) + expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() + + const listed = await rows(await openSheet()) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => + expect(posts(fetchMock)).toEqual([ + JSON.stringify({ through: UPDATES.updates[0]!.created_at }), + JSON.stringify({ through: reminder.created_at }), + ]), + ) + await waitFor(() => expect(homeReads(fetchMock)).toBe(4)) + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => { + const stored = [...UPDATES.updates] + let reads = 0 + let answer: (() => void) | undefined + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: () => { + const body = { updates: [...stored] } + reads++ + // Every reading after the first is held until the test answers it. + if (reads === 1) return jsonResponse(200, body) + return new Promise((resolve) => (answer = () => resolve(jsonResponse(200, body)))) + }, + }) + await renderHome() + await rows(await openSheet()) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + + // Closed and opened again at once, before any cache-collection timer + // could run, with a new event stored in between. + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) + const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) + + // The list held from the first opening is neither shown nor marked + // seen while this opening's read is in flight. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toHaveLength(1) + + answer!() + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => + expect(posts(fetchMock)).toEqual([ + JSON.stringify({ through: UPDATES.updates[0]!.created_at }), + JSON.stringify({ through: reminder.created_at }), + ]), + ) + }) + + it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => { + const stored = [...UPDATES.updates] + let firstAborted = false + let answerFirst: (() => void) | undefined + let reads = 0 + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: (init) => { + const body = { updates: [...stored] } + reads++ + if (reads > 1) return jsonResponse(200, body) + // The first reading is held open, and fails as fetch does if it is + // aborted. + return new Promise((resolve, reject) => { + answerFirst = () => resolve(jsonResponse(200, body)) + init?.signal?.addEventListener('abort', () => { + firstAborted = true + reject(init.signal!.reason as Error) + }) + }) + }, + }) + await renderHome() + await openSheet() + await waitFor(() => expect(updatesReads(fetchMock)).toBe(1)) + + // Closed before the first read answers, a new event stored, and opened + // again at once. + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) + const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) + + // The first read was cancelled, and the reopen sent its own. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(firstAborted).toBe(true) + // Answering the cancelled read changes nothing. + answerFirst!() + + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })])) + }) + + it.each([ + [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) => { + stubServer() + await renderHome() + const listed = await rows(await openSheet()) + + fireEvent.click(within(listed[i]!).getByRole('button')) + expect(await screen.findByTestId('where')).toHaveTextContent(to) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('says there are no updates yet, and marks nothing seen', async () => { + const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([]) + }) + + it('puts the dot back and says so when marking the updates seen fails', async () => { + let refuse!: (response: Response) => void + const fetchMock = stubServer({ + [`POST ${UPDATES_SEEN_PATH}`]: () => new Promise((resolve) => (refuse = resolve)), + }) + await renderHome() + const sheet = await openSheet() + + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + // Cleared at once, while the mark is in flight. + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + + refuse(errorResponse(500, 'internal')) + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + "Couldn't mark your updates as seen. internal message", + ) + expect(screen.getByTestId('unread-dot')).toBeInTheDocument() + // The updates are still listed. + expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument() + }) + + it('reports updates it cannot load, with Retry', async () => { + let fail = true + stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message") + fail = false + fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' })) + expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument() + }) + + it('reports an update that would open another site rather than listing it', async () => { + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }), + }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + 'update url "//evil.example/plan" is not a path on this app', + ) + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([]) + }) +}) diff --git a/web/src/home/UpdatesSheet.tsx b/web/src/home/UpdatesSheet.tsx new file mode 100644 index 0000000..2c000c2 --- /dev/null +++ b/web/src/home/UpdatesSheet.tsx @@ -0,0 +1,117 @@ +import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react' +import { useEffect, useId, useState } from 'react' +import { useNavigate } from 'react-router' +import { ago } from '../plan/PlanPage.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts' +import styles from './UpdatesSheet.module.css' + +const ICONS: Record = { + plan_ready: CalendarDays, + list_ready: ShoppingCart, + day_of_reminder: ChefHat, + new_suggestions: Compass, + rotation_review: Repeat, +} + +// UpdateRow is one update: its icon, title and body, and how long ago it +// came. A tap opens its url. +function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) { + return ( +
  • + } + title={update.title} + meta={update.body === '' ? undefined : update.body} + trailing={ + + } + onClick={() => onOpen(update.url)} + /> +
  • + ) +} + +// Updates lists the Recent updates, newest first, and marks them seen +// through the newest once they are shown, so the bell's dot clears. +function Updates({ onOpen }: { onOpen: (url: string) => void }) { + const updates = useUpdates() + const markSeen = useMarkUpdatesSeen() + const { mutate } = markSeen + // fresh says this opening's read has come back; until then a list still + // held from an earlier opening is neither shown nor marked seen. + const fresh = updates.isSuccess && updates.isFetchedAfterMount + const newest = fresh ? updates.data[0]?.created_at : undefined + // The instant the relative times count from, fixed while the sheet is + // open. + const [now] = useState(() => new Date()) + + useEffect(() => { + if (newest !== undefined) mutate(newest) + }, [newest, mutate]) + + if (updates.isError) { + return ( + void updates.refetch()}>Couldn't load your updates. {updates.error.message} + ) + } + if (!fresh) { + return ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } + if (updates.data.length === 0) { + return

    No updates yet. Plans, Supply runs and new Side quests show up here.

    + } + return ( + <> + {markSeen.isError && newest !== undefined && ( + mutate(newest)}> + Couldn't mark your updates as seen. {markSeen.error.message} + + )} +
      + {updates.data.map((update, i) => ( + + ))} +
    + + ) +} + +export type UpdatesSheetProps = { + open: boolean + onClose: () => void +} + +// UpdatesSheet is the Recent updates sheet the Home bell opens: the last +// ten notification events with how long ago each came, each opening the +// screen it is about. Opening it marks them seen. +export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) { + const titleId = useId() + const navigate = useNavigate() + const openUpdate = (url: string) => { + onClose() + void navigate(url) + } + return ( + +
    +

    + Recent updates +

    + +
    +
    + ) +} diff --git a/web/src/home/api.test.ts b/web/src/home/api.test.ts new file mode 100644 index 0000000..0810ff6 --- /dev/null +++ b/web/src/home/api.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { HOME } from '../test/ApiProviders.tsx' +import { parseHome, parseUpdates } from './api.ts' + +const origin = 'https://mealquest.example' +const where = 'GET /api/v1/updates' + +// body is a GET /api/v1/updates body holding one update that opens url. +function body(url: string) { + return { + updates: [{ type: 'plan_ready', title: 'Your week is drafted', body: '', url, created_at: '2026-10-10T16:00:00.000Z' }], + } +} + +describe('parseUpdates', () => { + it('keeps a same-origin path with its query and fragment, as parsed', () => { + expect(parseUpdates(where, body('/plan/shopping?week=2026-10-05#tuesday'), origin)[0]!.url).toBe( + '/plan/shopping?week=2026-10-05#tuesday', + ) + expect(parseUpdates(where, body('/plan/../discover'), origin)[0]!.url).toBe('/discover') + }) + + // Each of these would open another site, or run script, once parsed: URL + // parsing strips tabs and newlines and reads a backslash as a slash. + it.each([ + ['a tab', '/\t/evil.example/x'], + ['a newline', '/\n/evil.example/x'], + ['a carriage return', '/\r/evil.example/x'], + ['a backslash', '/\\evil.example/x'], + ['dot segments', '/..//evil.example/x'], + ['a protocol-relative URL', '//evil.example/x'], + ['an absolute URL', 'https://evil.example/x'], + ['javascript:', 'javascript:alert(1)'], + ['a relative path', 'plan'], + ])('refuses an update url with %s', (_, url) => { + expect(() => parseUpdates(where, body(url), origin)).toThrow( + `${where}: update url ${JSON.stringify(url)} is not a path on this app`, + ) + }) +}) + +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', + ) + }) +}) diff --git a/web/src/home/api.ts b/web/src/home/api.ts new file mode 100644 index 0000000..b466608 --- /dev/null +++ b/web/src/home/api.ts @@ -0,0 +1,230 @@ +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 { untilLocalMidnight, weekLabel } from '../plan/week.ts' +import { appPath } from '../push/notification.ts' + +export const HOME_PATH = '/api/v1/home' +export const HOME_KEY = ['home'] as const +export const UPDATES_PATH = '/api/v1/updates' +export const UPDATES_SEEN_PATH = '/api/v1/updates/seen' +export const UPDATES_KEY = ['updates'] as const + +export const ORIGINS = ['regular', 'side_quest'] as const +export type Origin = (typeof ORIGINS)[number] + +// Tonight is the dinner planned for today: photo_id is its stored photo's +// id, from photo, and minutes_total its minutes from prep to table, a +// positive whole number, or null when the recipe does not say. +export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null } + +// ShoppingListStatus is the Supply run Home points at: next week's once it +// has one, otherwise this week's, ready when that week has a list. +// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the +// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 · +// Oct 5 – 11"). +export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean } + +// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday, +// 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 + unread: boolean +} + +function parseTonight(where: string, body: unknown): Tonight | null { + if (body === null) return null + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: tonight is not a dinner`) + } + if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`) + const minutes = body.minutes_total + if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) { + throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`) + } + return { + id: body.id, + name: body.name, + photo_id: photoId(where, body.photo), + origin: oneOf(where, 'origin', ORIGINS, body.origin), + minutes_total: minutes as number | null, + } +} + +// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The +// list, ready or not, must carry the API's label for its week, which must be +// the label weekStart has, since the status row shows dates from weekStart. +function parseShoppingList(where: string, list: unknown): ShoppingListStatus { + if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') { + throw new Error(`${where}: shoppingList is not a week's Supply run status`) + } + const week = weekLabel(list.weekStart) + if (typeof list.label !== 'string' || list.label === '') { + throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`) + } + if (list.label !== `${week.week} · ${week.dates}`) { + throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`) + } + return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready } +} + +// 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`) + } + const updates = body.recentUpdates + 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), + unread: updates.unread, + } +} + +// 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() + const queryClient = useQueryClient() + const home = useQuery({ + queryKey: HOME_KEY, + queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch(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 +export type UpdateType = (typeof UPDATE_TYPES)[number] + +// Update is one of the Recent updates: a notification event, whatever +// channels it went to. url is the same-origin path it opens; created_at is +// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen +// takes back. +export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string } + +// parseUpdate checks one update. Its url must stay on origin once parsed, as +// the server only ever stores a path on the app's own origin; url is that +// path as parsed. +function parseUpdate(where: string, body: unknown, origin: string): Update { + if ( + !isRecord(body) || + typeof body.title !== 'string' || + typeof body.body !== 'string' || + typeof body.url !== 'string' || + typeof body.created_at !== 'string' + ) { + throw new Error(`${where}: an update is not a notification event`) + } + const url = appPath(body.url, origin) + if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`) + if (Number.isNaN(Date.parse(body.created_at))) { + throw new Error(`${where}: update created_at "${body.created_at}" is not a time`) + } + return { + type: oneOf(where, 'type', UPDATE_TYPES, body.type), + title: body.title, + body: body.body, + url, + created_at: body.created_at, + } +} + +// parseUpdates checks a GET /api/v1/updates body, from the app at origin: +// the updates, newest first. +export function parseUpdates(where: string, body: unknown, origin: string): Update[] { + if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`) + return body.updates.map((update) => parseUpdate(where, update, origin)) +} + +// useUpdates is the Recent updates (GET /api/v1/updates): the last ten +// notification events, newest first. Each opening of the sheet reads it +// afresh (refetchOnMount 'always'), whatever the query client still holds; +// the sheet shows, and marks seen, only the list that opening read. Closing +// the sheet aborts a read still in flight, so a reopen sends its own rather +// than adopt one sent before it. +export function useUpdates() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: UPDATES_KEY, + queryFn: async ({ signal }) => + parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH, { signal }), window.location.origin), + refetchOnMount: 'always', + }) +} + +const MARK_SEEN_KEY = ['updates', 'seen'] as const + +// useMarkUpdatesSeen records that the household has seen its updates +// through the created_at of the newest one shown (POST +// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home +// read in flight is stopped so it cannot bring it back; if the server +// refuses, the dot is put back as it was before the first of the marks in +// flight. Once the last mark in flight settles Home is read again, so the +// dot shows only for an event stored since. +export function useMarkUpdatesSeen() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + // alone says whether this is the only mark in flight; each counts itself + // until it has settled. + const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1 + return useMutation({ + mutationKey: MARK_SEEN_KEY, + mutationFn: async (through: string) => { + await apiFetch(UPDATES_SEEN_PATH, { method: 'POST', body: { through } }) + }, + onMutate: async () => { + // A mark sent while another is in flight would snapshot that one's + // optimistic answer, so only the first snapshots. + const unread = alone() ? queryClient.getQueryData(HOME_KEY)?.unread : undefined + await queryClient.cancelQueries({ queryKey: HOME_KEY }) + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread: false }) + return { unread } + }, + onError: (_error, _through, context) => { + if (context?.unread === undefined || !alone()) return + const { unread } = context + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread }) + }, + onSettled: () => { + if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY }) + }, + }) +} diff --git a/web/src/home/rotation.ts b/web/src/home/rotation.ts new file mode 100644 index 0000000..27375ae --- /dev/null +++ b/web/src/home/rotation.ts @@ -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(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) => { + 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({ + mutationFn: async ({ review, decision }) => { + const path = rotationReviewPath(review.dinner.id) + const body = await apiFetch(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(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 }) + }, + }) +} diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx index 55ec2a4..9876fe7 100644 --- a/web/src/onboarding/NotificationsStep.test.tsx +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts' import App from '../App.tsx' import { AuthBootstrap } from '../AuthBootstrap.tsx' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' @@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V const path = String(input) const method = init?.method ?? 'GET' if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME)) if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey()) if (method === 'POST' && path === '/api/v1/push/subscriptions') { subscriptions.push(JSON.parse(String(init?.body))) @@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) { } /> - Home} /> + Tonight's quest} /> Regulars step} /> @@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType) { await vi.waitFor(() => expect(server.held).toHaveLength(1)) expect(server.held[0]!.body).toEqual({ onboarding_completed: true }) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() } function useIphoneSafariTab() { @@ -210,7 +211,7 @@ describe('onboarding step 3', () => { expect(later()).toBeDisabled() completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() expect(requestPermission).not.toHaveBeenCalled() }) @@ -314,7 +315,7 @@ describe('onboarding step 3', () => { await vi.waitFor(() => expect(server.held).toHaveLength(1)) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(browser.notification.requestPermission).not.toHaveBeenCalled() expect(browser.pushManager.subscribe).not.toHaveBeenCalled() expect(server.subscriptions).toEqual([]) @@ -358,7 +359,7 @@ describe('onboarding step 3', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() await vi.waitFor(() => expect(server.held).toHaveLength(2)) completed(server.held[1]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() }) }) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index fe90d5f..77c9867 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -160,16 +160,25 @@ function planReads(fetchMock: Mock) { } // 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 = ) { +// the Plan page at entry, over queryClient, with shopping as the Supply run +// screen. +async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = , entry = '/plan') { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + - } /> + + + + + } + /> @@ -190,6 +199,11 @@ function Opened() { ) } +// Searched shows the Plan's search, the week it keeps in ?week=. +function Searched() { + return

    {useLocation().search}

    +} + // 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(), , '/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(), , '/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')), diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 7f3265f..d15f80f 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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(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(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 = {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} + } else if (settings.isError) { content = ( void settings.refetch()}>Couldn't load the household settings. {settings.error.message} ) diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index d3ad06d..3459c50 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -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) + }) }) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index b2c82d0..f62da2c 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -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 +} diff --git a/web/src/push/notification.test.ts b/web/src/push/notification.test.ts index 2c432ae..c7061b6 100644 --- a/web/src/push/notification.test.ts +++ b/web/src/push/notification.test.ts @@ -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() }) diff --git a/web/src/push/notification.ts b/web/src/push/notification.ts index b86c9c4..b77daf6 100644 --- a/web/src/push/notification.ts +++ b/web/src/push/notification.ts @@ -150,6 +150,18 @@ export function planClick(windows: readonly W[], path: stri export type ClickWindow = AppWindow & { focus(): Promise navigate(url: string): Promise + 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 = { @@ -158,7 +170,8 @@ export type ClickClients = { } // 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( data: unknown, clients: ClickClients, @@ -171,4 +184,5 @@ export async function openNotification( } await plan.window.focus() if (plan.navigate) await plan.window.navigate(plan.url) + else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url }) } diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index 839e8e2..7c889b8 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -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 } - -// isMonday says whether date is a real YYYY-MM-DD Monday. -function isMonday(date: string): boolean { - try { - return weekOf(date) === date - } catch { - return false - } -} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 4405812..febea05 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -61,6 +61,18 @@ export const GOOGLE_NOT_CONNECTED = { last_sync_error: null, } +// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a +// Regular, tonight; ten Side quests; next week's Supply run ready; an +// 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 }, + recentUpdates: { unread: true }, +} + // NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the // seed channels and reminder time. export const NOTIFICATION_SETTINGS = {