Feature 21: Home Dashboard (#181)
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #181.
This commit is contained in:
2026-10-10 20:07:53 -04:00
committed by approver_agent
parent ecf7c6be89
commit fd087e0ba3
59 changed files with 3923 additions and 87 deletions
+7 -1
View File
@@ -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
}
+62 -5
View File
@@ -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": "<created_at>"}` (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,
+2 -2
View File
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
t.Errorf("plan text = %q", reqs[0].Body["text"])
}
// The cards' "Open" buttons link under the public URL.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
// The cards' "Open" buttons link under the public URL to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
+3 -2
View File
@@ -2,6 +2,7 @@ package chat
import (
"fmt"
"net/url"
"strings"
"time"
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan",
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
URL: "/plan/shopping",
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
+6 -6
View File
@@ -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",
+63
View File
@@ -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
+14
View File
@@ -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
+17
View File
@@ -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 = ?
+6
View File
@@ -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 }
+253
View File
@@ -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
}
+598
View File
@@ -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")
}
}
+16
View File
@@ -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) {
+55
View File
@@ -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 {
+2 -2
View File
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
}
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+85
View File
@@ -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)
}
}
+245
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
+10 -2
View File
@@ -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))
+94 -12
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
}
}
+11
View File
@@ -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.
+25
View File
@@ -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);
+8
View File
@@ -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));
+28 -8
View File
@@ -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()
+2
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/': <HomePage />,
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
+193
View File
@@ -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;
}
+319
View File
@@ -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 <p data-testid="where">{location.pathname + location.search}</p>
}
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: <HomePage /> },
{ path: '*', element: <Where /> },
],
{ initialEntries: ['/'] },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
}
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<typeof fetch>(() => 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()
})
})
+158
View File
@@ -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<Origin, string> = { 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 (
<section aria-label="Tonight" className={styles.card}>
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.cardFoot}>
<div className={styles.cardText}>
<h2 className={styles.name}>{tonight.name}</h2>
<span className={styles.meta}>{meta(tonight)}</span>
</div>
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
Recipe
</Link>
</div>
</section>
)
}
// NoPlan stands in for the Tonight card when today has no plan.
function NoPlan() {
const navigate = useNavigate()
return (
<div className={styles.card}>
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
Draft this week's dinners and tonight's quest shows up here.
</EmptyState>
</div>
)
}
// 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 (
<li>
<Link to={to} className={styles.status}>
<Icon icon={icon} className={styles.statusIcon} />
<span className={styles.statusText}>{children}</span>
<Icon icon={ChevronRight} />
</Link>
</li>
)
}
// 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 (
<ul aria-label="Status" className={styles.statuses}>
{count > 0 && (
<StatusRow to="/discover" icon={Compass}>
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
</StatusRow>
)}
{list.ready && (
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
{`Shopping list ready · ${list.dates}`}
</StatusRow>
)}
</ul>
)
}
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
function Tiles() {
return (
<div className={styles.tiles}>
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
<Icon icon={Zap} size="nav" />
<span className={styles.tileLabel}>Quick dinner</span>
</Link>
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
<Icon icon={Search} size="nav" />
<span className={styles.tileLabel}>Cook from what I have</span>
</Link>
</div>
)
}
// 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 (
<div className={styles.page} aria-busy={home.isPending}>
<header className={styles.header}>
<div>
{data !== undefined ? (
<p className={styles.date}>{data.dateLabel}</p>
) : (
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
)}
<h1 className={styles.title}>Tonight's quest</h1>
</div>
<IconButton
label={data?.unread ? 'Recent updates, unread' : 'Recent updates'}
className={styles.bell}
onClick={() => setUpdatesOpen(true)}
>
<Icon icon={Bell} className={styles.bellIcon} />
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
</IconButton>
</header>
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
{home.isError && (
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
)}
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
<RotationCheck />
<Tiles />
{home.isPending && (
<div className={styles.statuses} aria-hidden="true">
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
</div>
)}
{data !== undefined && <StatusRows home={data} />}
<UpdatesSheet open={updatesOpen} onClose={() => setUpdatesOpen(false)} />
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
.card {
display: flex;
flex-direction: column;
border-radius: 32px;
background: var(--color-surface);
overflow: hidden;
}
.photo {
height: 120px;
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 18px 12px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.question {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 6px;
}
/* "We didn't make it": a text link at the 44 px tap minimum. */
.notMade {
align-self: center;
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.notMade:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.notMade:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.confirmation {
margin: 0;
padding: 12px 14px;
border-radius: 20px;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
+320
View File
@@ -0,0 +1,320 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
import { QueryClientProvider } from '@tanstack/react-query'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
// /api/v1/rotation-reviews sends them.
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'serviceWorker')
})
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
// stubApi answers Home's reads like the server: the pending Rotation checks
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
// decisions until the test answers it; a 200 answer takes the check off the
// server's list, as the server does.
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
const server = [...reviews]
const decisions: Deferred[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
if (init?.method === 'POST' && review !== undefined) {
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
const body: unknown = JSON.parse(String(init.body))
return new Promise<Response>((resolve) =>
decisions.push({
path,
body,
resolve: (response) => {
if (response.ok) server.splice(server.indexOf(review), 1)
resolve(response)
},
}),
)
}
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, decisions, server }
}
// decided is the server's answer to a decision on review.
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
return jsonResponse(200, {
dinner: review.dinner,
plan_date: review.plan_date,
decision,
dinner_status: dinnerStatus,
decided_at: '2026-10-02T13:00:00Z',
updated_by: 'sub-1',
})
}
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
// at path.
async function renderHome(path = '/') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
}
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
describe('RotationCheck', () => {
it('shows no card when nothing waits for an answer', async () => {
const { fetchMock } = stubApi([])
await renderHome()
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
})
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
const keep = within(check).getByRole('button', { name: 'Keep it' })
const drop = within(check).getByRole('button', { name: 'Not for us' })
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
expect(keep.className).toMatch(/primary/)
expect(drop.className).toMatch(/outline/)
expect(notMade.className).toMatch(/notMade/)
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The card waits for the household: nothing is sent unasked.
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
expect(posts(fetchMock)).toHaveLength(0)
expect(await card()).toBeInTheDocument()
})
it.each([
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: label }))
// Applied before the server answers.
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
expect(posts(fetchMock)).toHaveLength(1)
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
// Rotation checks are read again once saved; none is left.
await waitFor(() =>
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
)
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
const next = await card()
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
await waitFor(() => expect(decisions).toHaveLength(2))
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
})
it('puts the check back with an ErrorBanner when the answer fails', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
const { decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The server closed the check, so the list read again has none.
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
})
it('reports Rotation checks it cannot load, with Retry', async () => {
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives; a notification tap there reads the checks again.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('opens Home with the card from a rotation_review push notification', async () => {
stubApi([SHAKSHUKA])
const origin = 'https://mealquest.test'
const { notice } = parsePushPayload(
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
origin,
)
const path = clickPath({ url: notice.url }, origin)
expect(path).toBe('/')
await renderHome(path)
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const { fetchMock, server } = stubApi([])
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
await renderHome('/')
const home = await screen.findByRole('heading', { name: 'Pizza night' })
await waitFor(() => expect(reads()).toBe(1))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
// Within the cached list's staleTime the Rotation check comes due and
// its notification is tapped while Home is open at /.
server.push(SHAKSHUKA)
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(win.navigate).not.toHaveBeenCalled()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(reads()).toBe(2)
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { Button } from '../ui/Button.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Photo } from '../ui/Photo.tsx'
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
import styles from './RotationCheck.module.css'
// CONFIRMATION is what Home says once the household has answered.
const CONFIRMATION: Record<Decision, string> = {
keep: 'Added to your Regulars',
drop: "Got it — we won't suggest it again",
not_made: 'Back in your Quest log',
}
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
// photo, "Last night: {dinner}", and the three answers.
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
const { dinner } = review
return (
<section aria-label="Keep it in rotation?" className={styles.card}>
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.body}>
<p className={styles.kicker}>Last night: {dinner.name}</p>
<h2 className={styles.question}>Keep it in rotation?</h2>
<div className={styles.actions}>
<Button disabled={disabled} onClick={() => onDecide('keep')}>
Keep it
</Button>
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
Not for us
</Button>
</div>
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
We didn't make it
</button>
</div>
</section>
)
}
// RotationCheck is the Rotation check on Home: the card for the oldest
// pending check (GET /api/v1/rotation-reviews), which stays until the
// household answers it — nothing is decided for them. An answer is applied
// at once: Home confirms it and shows the next pending check, if any, while
// the answer is saved; the answers are locked until it settles. A failed
// answer puts its check back with an ErrorBanner. A failed read shows an
// ErrorBanner, beside the checks last read: a card already shown stays
// until it is answered.
export function RotationCheck() {
const reviews = useRotationReviews()
const decide = useDecideRotationReview()
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
return (
<>
{answered !== undefined && (
<p role="status" className={styles.confirmation}>
{CONFIRMATION[answered.decision]}
</p>
)}
{reviews.isError && (
<ErrorBanner onRetry={() => void reviews.refetch()}>
Couldn't load the Rotation check. {reviews.error.message}
</ErrorBanner>
)}
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
{next !== undefined && (
<RotationCard
key={next.dinner.id}
review={next}
disabled={decide.isPending}
onDecide={(decision) => decide.mutate({ review: next, decision })}
/>
)}
</>
)
}
+49
View File
@@ -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);
}
+383
View File
@@ -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<Response>
// 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<string, Responder> = {}) {
let seen = false
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
[`POST ${UPDATES_SEEN_PATH}`]: () => {
seen = true
return new Response(null, { status: 204 })
},
...responders,
}
const fetchMock = vi.fn<typeof fetch>((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<typeof fetch>) {
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<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET')
.length
}
function updatesReads(fetchMock: Mock<typeof fetch>) {
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 <p data-testid="where">{location.pathname + location.search}</p>
}
// 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: <HomePage /> },
{ path: '*', element: <Where /> },
],
{ initialEntries: ['/'] },
)
const queryClient = createQueryClient()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
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<Response>((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<Response>((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<Response>((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([])
})
})
+117
View File
@@ -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<UpdateType, LucideIcon> = {
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 (
<li>
<ListRow
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
title={update.title}
meta={update.body === '' ? undefined : update.body}
trailing={
<time dateTime={update.created_at} className={styles.time}>
{ago(new Date(update.created_at), now)}
</time>
}
onClick={() => onOpen(update.url)}
/>
</li>
)
}
// 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 (
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
)
}
if (!fresh) {
return (
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeletonRow} />
))}
</div>
)
}
if (updates.data.length === 0) {
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
}
return (
<>
{markSeen.isError && newest !== undefined && (
<ErrorBanner onRetry={() => mutate(newest)}>
Couldn't mark your updates as seen. {markSeen.error.message}
</ErrorBanner>
)}
<ul aria-label="Recent updates" className={styles.rows}>
{updates.data.map((update, i) => (
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
))}
</ul>
</>
)
}
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 (
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
Recent updates
</h2>
<Updates onOpen={openUpdate} />
</div>
</Sheet>
)
}
+52
View File
@@ -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',
)
})
})
+230
View File
@@ -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<unknown>(HOME_PATH)),
})
const zone = home.data?.timezone
const readAt = home.dataUpdatedAt
useEffect(() => {
if (zone === undefined) return
const timer = setTimeout(
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
untilLocalMidnight(zone, new Date()),
)
return () => clearTimeout(timer)
}, [queryClient, zone, readAt])
return home
}
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
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<unknown>(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<unknown>(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>(HOME_KEY)?.unread : undefined
await queryClient.cancelQueries({ queryKey: HOME_KEY })
queryClient.setQueryData<Home>(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>(HOME_KEY, (home) => home && { ...home, unread })
},
onSettled: () => {
if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY })
},
})
}
+107
View File
@@ -0,0 +1,107 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { photoId } from '../dinners/api.ts'
import { isNotificationOpened } from '../push/notification.ts'
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
const DINNERS_KEY = ['dinners'] as const
// rotationReviewPath is where the household answers "Keep it in rotation?"
// for a tried dinner.
export function rotationReviewPath(dinnerId: string): string {
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
}
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
// for us") archives it so it is never suggested again, and not_made ("We
// didn't make it") returns it to the Quest log. Keep is the only way a tried
// dinner becomes a Regular; nothing decides one automatically.
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
export type Decision = (typeof DECISIONS)[number]
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
// planned on.
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
function parseReview(where: string, body: unknown): RotationReview {
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
const dinner = body.dinner
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: a Rotation check has no dinner`)
}
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
}
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
// pending Rotation checks, oldest plan date first.
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
return body.reviews.map((review) => parseReview(where, review))
}
// useRotationReviews is the pending Rotation checks (GET
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
// reads them afresh, and so does a notification tap that focuses Home
// already open (the service worker's NotificationOpened), so Home opened
// from a rotation_review notification shows the check it is about.
export function useRotationReviews() {
const { apiFetch } = useApi()
const query = useQuery({
queryKey: ROTATION_REVIEWS_KEY,
queryFn: async () =>
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
refetchOnMount: 'always',
})
const { refetch } = query
useEffect(() => {
// Without a service worker there are no notifications to tap.
if (!('serviceWorker' in navigator)) return
const container = navigator.serviceWorker
const onMessage = (event: MessageEvent<unknown>) => {
if (isNotificationOpened(event.data)) void refetch()
}
container.addEventListener('message', onMessage)
return () => container.removeEventListener('message', onMessage)
}, [refetch])
return query
}
// useDecideRotationReview answers "Keep it in rotation?" for a pending
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
// must be that dinner, decided that way.
//
// Home leaves the check out while its answer is pending (the mutation's
// variables say which), so the next one shows at once; a read in flight is
// stopped so it cannot bring the check back meanwhile. Once saved, the check
// leaves the cached list; once failed, it is shown again as the list has it.
// Either way the Rotation checks and the dinners lists (the Regulars and the
// Quest log the answer moved it to) are read again.
export function useDecideRotationReview() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
mutationFn: async ({ review, decision }) => {
const path = rotationReviewPath(review.dinner.id)
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
},
onSuccess: (_data, { review }) => {
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
)
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
},
})
}
@@ -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) {
<AuthBootstrap>
<Routes>
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
<Route path="/" element={<h1>Home</h1>} />
<Route path="/" element={<h1>Tonight's quest</h1>} />
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
</Routes>
</AuthBootstrap>
@@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
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()
})
})
+42 -4
View File
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
// the Plan page at entry, over queryClient, with shopping as the Supply run
// screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route
path="/plan"
element={
<>
<PlanPage />
<Searched />
</>
}
/>
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter>
@@ -190,6 +199,11 @@ function Opened() {
)
}
// Searched shows the Plan's search, the week it keeps in ?week=.
function Searched() {
return <p data-testid="search">{useLocation().search}</p>
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps.
function pushesKept(queryClient: QueryClient) {
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
stubServer({
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...lists,
})
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
// The week buttons keep ?week= on the week shown.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
})
it('says so when ?week= is not a Monday, and reads no plan', async () => {
const fetchMock = stubServer({ ...lists })
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
})
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
+17 -7
View File
@@ -1,13 +1,13 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Link, useSearchParams } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
@@ -40,6 +40,7 @@ import {
addDays,
dayOfMonth,
defaultWeek,
isMonday,
localNow,
weekdayKicker,
weekdayName,
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
// PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
// Regulars in a right panel on desktop (2d). It opens on next week once this
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
// ?week=, as a plan_ready update links it, else on next week once this
// week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
export function PlanPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
const [params, setParams] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
// The default week is chosen once, not on every render: the plan_draft
// time passing does not move the page to another week.
const [opened, setOpened] = useState<string | null>(null)
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
const week = chosen ?? opened
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
let content
if (settings.isError) {
if (chosen !== null && !isMonday(chosen)) {
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
} else if (settings.isError) {
content = (
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
)
+54 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest'
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
import {
addDays,
dayAndDate,
dayOfMonth,
defaultWeek,
isMonday,
localNow,
untilLocalMidnight,
weekdayKicker,
weekdayName,
weekLabel,
weekStart,
} from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -72,4 +84,45 @@ describe('week helpers', () => {
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
})
it('says whether a date is a real Monday', () => {
expect(isMonday('2026-10-05')).toBe(true)
expect(isMonday('2026-10-07')).toBe(false)
expect(isMonday('2026-02-30')).toBe(false)
expect(isMonday('next week')).toBe(false)
})
it('counts the time to the next midnight in the household zone, not UTC', () => {
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
// At midnight itself the next is a day away.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
// The same instant is 13:00 in Tokyo: eleven hours to go.
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
})
it('counts a day shortened by the spring clock change as 23 hours', () => {
const hour = 60 * 60 * 1000
// 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the
// next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour)
// 03:30 EDT, after the change: 20.5 hours to midnight.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour)
// At midnight starting the 23-hour day, the next midnight is 23 hours away.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour)
// Noon EST the day before: the change is after the coming midnight.
expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour)
})
it('counts a day lengthened by the autumn clock change as 25 hours', () => {
const hour = 60 * 60 * 1000
// Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00.
expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour)
})
it('starts the next day at the clock change when the clocks skip midnight', () => {
// Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z);
// at 23:00 -04 on September 5 the next day starts an hour later.
expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000)
})
})
+45
View File
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
return addDays(date, -sinceMonday)
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
export function isMonday(date: string): boolean {
try {
return weekStart(date) === date
} catch {
return false
}
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
@@ -115,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek
}
// wallClock is the household-local wall clock at instant t in IANA zone,
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
function wallClock(zone: string, t: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(t))
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
return wall + (((t % 1000) + 1000) % 1000)
}
// untilLocalMidnight is how many milliseconds after instant now the next
// local day begins in IANA zone. It finds the actual instant of the next
// day's midnight, so a day shortened or lengthened by a change of UTC offset
// is counted as it is. The candidates are that midnight at the zone's offset
// now and at the offset that candidate lands on; the earliest that falls on
// the next day is its start, which is the offset change itself when the
// clocks skip midnight.
export function untilLocalMidnight(zone: string, now: Date): number {
const t = now.getTime()
const tomorrow = addDays(localNow(zone, now).date, 1)
const midnight = midnightUTC(tomorrow).getTime()
const first = midnight - (wallClock(zone, t) - t)
const second = midnight - (wallClock(zone, first) - first)
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
return Math.min(...starts) - t
}
+14
View File
@@ -5,6 +5,7 @@ import {
GENERIC_NOTICE,
appPath,
clickPath,
isNotificationOpened,
openNotification,
parsePushPayload,
planClick,
@@ -178,6 +179,7 @@ describe('openNotification', () => {
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: vi.fn(),
} satisfies ClickWindow
}
@@ -193,6 +195,18 @@ describe('openNotification', () => {
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(win.postMessage).not.toHaveBeenCalled()
expect(all.openWindow).not.toHaveBeenCalled()
})
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/' }, all, origin)
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).not.toHaveBeenCalled()
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
expect(all.openWindow).not.toHaveBeenCalled()
})
+15 -1
View File
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
export type ClickWindow = AppWindow & {
focus(): Promise<unknown>
navigate(url: string): Promise<unknown>
postMessage(message: NotificationOpened): void
}
// NotificationOpened is the message a tap posts to an open window it focuses
// without navigating, because the window is already at url: nothing remounts
// there, so the screen reads afresh what the notification is about.
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
// isNotificationOpened reports whether a message from the service worker is
// NotificationOpened.
export function isNotificationOpened(message: unknown): message is NotificationOpened {
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
}
export type ClickClients<W extends ClickWindow> = {
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
// first, while the tap still allows it, then navigate; a window already at
// the url is told NotificationOpened instead.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
}
await plan.window.focus()
if (plan.navigate) await plan.window.navigate(plan.url)
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
}
+1 -10
View File
@@ -1,7 +1,7 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+12
View File
@@ -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 = {