From dbb6310112a9af7b6d5be23e733d91aa4402bcb2 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 20:52:29 -0400 Subject: [PATCH 1/5] Home API (Feature 21) (#173) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 8 +- docs/ARCHITECTURE.md | 28 ++ internal/db/notifications.sql.go | 16 ++ internal/db/querier.go | 3 + internal/home/export_test.go | 6 + internal/home/home.go | 197 ++++++++++++++ internal/home/home_test.go | 372 +++++++++++++++++++++++++++ internal/notify/notify.go | 16 ++ internal/server/aisettings_test.go | 2 +- internal/server/chat_test.go | 4 +- internal/server/dinners_test.go | 2 +- internal/server/google_test.go | 2 +- internal/server/home.go | 22 ++ internal/server/home_test.go | 69 +++++ internal/server/ideaactions_test.go | 2 +- internal/server/ideas_test.go | 2 +- internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans_test.go | 2 +- internal/server/push_test.go | 4 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 9 +- internal/server/server_test.go | 94 ++++++- internal/server/settings_test.go | 2 +- internal/server/suggestions_test.go | 2 +- migrations/queries/notifications.sql | 8 + 27 files changed, 849 insertions(+), 31 deletions(-) create mode 100644 internal/home/export_test.go create mode 100644 internal/home/home.go create mode 100644 internal/home/home_test.go create mode 100644 internal/server/home.go create mode 100644 internal/server/home_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7103695..c472419 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -171,6 +172,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 @@ -187,7 +193,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ebae68..07e3b92 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1089,6 +1089,34 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. 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", + "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. `photo` and `minutes_total` are null when unknown. + `sideQuestCount` is the pending cards of the deck (`suggest.Deck`). + `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. Nothing writes `updates_seen_at` yet. + ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go index 41d92f2..3758e5d 100644 --- a/internal/db/notifications.sql.go +++ b/internal/db/notifications.sql.go @@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi return i, err } +const hasNotificationSince = `-- name: HasNotificationSince :one +SELECT EXISTS ( + SELECT 1 FROM notification_outbox + WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT) +) AS found +` + +// Reports whether a notification was stored after since, or at all when +// since is NULL. +func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) { + row := q.db.QueryRowContext(ctx, hasNotificationSince, since) + var found bool + err := row.Scan(&found) + return found, err +} + const insertNotification = `-- name: InsertNotification :execrows INSERT INTO notification_outbox ( id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at diff --git a/internal/db/querier.go b/internal/db/querier.go index c626259..2213f67 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -179,6 +179,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. diff --git a/internal/home/export_test.go b/internal/home/export_test.go new file mode 100644 index 0000000..c3db366 --- /dev/null +++ b/internal/home/export_test.go @@ -0,0 +1,6 @@ +package home + +import "time" + +// SetClock makes now the clock s reads "now" and "tonight" on. +func SetClock(s *Service, now func() time.Time) { s.now = now } diff --git a/internal/home/home.go b/internal/home/home.go new file mode 100644 index 0000000..73f21fb --- /dev/null +++ b/internal/home/home.go @@ -0,0 +1,197 @@ +// 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"` + // 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". +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 + } + deck, err := s.suggest.Deck(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"), + Tonight: tonight, + SideQuestCount: len(deck.Suggestions), + 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 +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 0000000..4742350 --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,372 @@ +package home_test + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "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 + 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, 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) + } + svc, err := home.NewService(household.NewSettingsService(st), 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, 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", + 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) + } +} + +// 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") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 4254e5b..ae73a5a 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -189,6 +189,22 @@ 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 +} + // emitTx stores ev in tx for each channel that channels chooses from the // preference of ev's type. func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error { diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index e5b4a1f..2679a39 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go index d1b6c7b..a55cfef 100644 --- a/internal/server/chat_test.go +++ b/internal/server/chat_test.go @@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string { func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) { func TestNewRouterRequiresChat(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Chat service") } } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 237a256..4a13e76 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -98,7 +98,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index ccdc4e0..c09c1ae 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/home.go b/internal/server/home.go new file mode 100644 index 0000000..f54e7fb --- /dev/null +++ b/internal/server/home.go @@ -0,0 +1,22 @@ +package server + +import ( + "log/slog" + "net/http" + + "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) + } +} diff --git a/internal/server/home_test.go b/internal/server/home_test.go new file mode 100644 index 0000000..f84d5ff --- /dev/null +++ b/internal/server/home_test.go @@ -0,0 +1,69 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// 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"), + "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) + } +} diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go index 238156a..c6dbf4c 100644 --- a/internal/server/ideaactions_test.go +++ b/internal/server/ideaactions_test.go @@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index 73b2d1c..35c77cd 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/login_test.go b/internal/server/login_test.go index ceb4c6f..3bd7234 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index 77490c7..d527d3b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 20bc97d..de89969 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 4b7224d..9910272 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/push_test.go b/internal/server/push_test.go index cf1779d..5ffdcff 100644 --- a/internal/server/push_test.go +++ b/internal/server/push_test.go @@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) func newPushRouter(t *testing.T, svc *push.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil push service") } } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 028887d..caf61fd 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index dcff31f..c5f5850 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -66,12 +67,12 @@ 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; 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 +106,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 +141,7 @@ 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("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e6c75ae..2ea9637 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -22,8 +22,10 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -56,7 +58,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "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), newTestHome(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -257,6 +259,65 @@ 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() + st := newTestStore(t) + 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, 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 +378,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 +387,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 +396,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 +405,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 +414,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 +423,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 +432,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 +441,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 +450,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 +477,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 84bd11f..bb8f67b 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index aaba602..aeac339 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql index c9deb42..83d0ac9 100644 --- a/migrations/queries/notifications.sql +++ b/migrations/queries/notifications.sql @@ -66,3 +66,11 @@ 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; -- 2.55.0 From ea5399098d0619eae9e7642feea609683db1443f Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Sat, 10 Oct 2026 08:53:07 -0400 Subject: [PATCH 2/5] Home screen (1g) with empty states (Feature 21) (#174) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/App.test.tsx | 30 +- web/src/App.tsx | 2 + web/src/home/HomePage.module.css | 193 ++++++++++++ web/src/home/HomePage.test.tsx | 288 ++++++++++++++++++ web/src/home/HomePage.tsx | 147 +++++++++ web/src/home/api.ts | 97 ++++++ web/src/onboarding/NotificationsStep.test.tsx | 13 +- web/src/test/ApiProviders.tsx | 11 + 8 files changed, 767 insertions(+), 14 deletions(-) create mode 100644 web/src/home/HomePage.module.css create mode 100644 web/src/home/HomePage.test.tsx create mode 100644 web/src/home/HomePage.tsx create mode 100644 web/src/home/api.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index af91d47..f73ad84 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' import { dinnerBody } from './test/dinners.ts' import { defaultWeek } from './plan/week.ts' @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { + '/': true, '/onboarding': true, '/plan': true, '/plan/shopping': true, @@ -85,6 +86,16 @@ describe('App routes', () => { }, ) + it('/ renders the Home screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) }) + 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,13 @@ describe('App bootstrap', () => { it('reports a /me failure with a retry instead of rendering the routes', async () => { mockViewport(390) - stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME)) 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 +294,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) }) 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 +307,16 @@ describe('App offline bootstrap', () => { }) // A test that emits an offline event leaves TanStack Query's online - // manager, shared by every test, offline until an online event. + // manager, shared by every test, offline until an online event. The + // manager listens for it only while a query client is mounted, so the + // event goes out before cleanup unmounts the app. afterEach(() => { - cleanup() window.dispatchEvent(new Event('online')) + cleanup() }) it.each([ - ['/', 'Home'], + ['/', "Tonight's quest"], ['/settings/notifications', 'Notifications'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() diff --git a/web/src/App.tsx b/web/src/App.tsx index deea5b5..54ad6d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' +import { HomePage } from './home/HomePage.tsx' import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' @@ -45,6 +46,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/': , '/plan': , '/plan/shopping': , '/plan/shopping/archive': , diff --git a/web/src/home/HomePage.module.css b/web/src/home/HomePage.module.css new file mode 100644 index 0000000..9cd4b56 --- /dev/null +++ b/web/src/home/HomePage.module.css @@ -0,0 +1,193 @@ +/* Home, handoff mock 1g. */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2px; +} + +.date { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.1; +} + +/* The bell's 8 px grape unread dot sits on the 40 px face, which is + centred in the IconButton's 44 px hit box. */ +.bell { + position: relative; +} + +.bell .bellIcon { + width: 19px; + height: 19px; +} + +.dot { + position: absolute; + top: 10px; + right: 11px; + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-accent); +} + +.card { + display: flex; + flex-direction: column; + border-radius: 32px; + background: var(--color-surface); + overflow: hidden; +} + +.photo { + height: 150px; +} + +.cardFoot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + padding: 2px 18px 16px; +} + +.cardText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.1; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */ +.recipe { + flex: none; + min-height: 44px; + padding: 0 16px; + font-size: 13px; + text-decoration: none; +} + +.tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.tile { + box-sizing: border-box; + height: 104px; + padding: 14px; + display: flex; + flex-direction: column; + justify-content: space-between; + border-radius: 26px; + text-decoration: none; +} + +.quick { + background: var(--color-accent); + color: var(--on-accent); +} + +.pantry { + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); +} + +.tileLabel { + font-family: var(--font-heading); + font-size: 16px; + line-height: 1.1; +} + +.statuses { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.status { + box-sizing: border-box; + min-height: 44px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 20px; + background: var(--color-neutral-100); + color: var(--color-text); + text-decoration: none; +} + +.status .statusIcon { + width: 18px; + height: 18px; + color: var(--color-accent-700); +} + +.statusText { + flex: 1; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +.tile:focus-visible, +.status:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.skeletonDate { + width: 96px; + height: 14px; + margin-bottom: 2px; +} + +.skeletonCard { + height: 238px; + border-radius: 32px; +} + +.skeletonStatus { + height: 44px; + border-radius: 20px; +} diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx new file mode 100644 index 0000000..5a2f838 --- /dev/null +++ b/web/src/home/HomePage.test.tsx @@ -0,0 +1,288 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiProviders, HOME } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { HOME_PATH } from './api.ts' +import { HomePage } from './HomePage.tsx' + +// 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.unstubAllGlobals() +}) + +// Where shows the route a link went to. +function Where() { + const location = useLocation() + return

{location.pathname + location.search}

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

{tonight.name}

+ {meta(tonight)} +
+ + Recipe + +
+
+ ) +} + +// NoPlan stands in for the Tonight card when today has no plan. +function NoPlan() { + const navigate = useNavigate() + return ( +
+ void navigate('/plan') }}> + Draft this week's dinners and tonight's quest shows up here. + +
+ ) +} + +// StatusRow is one neutral row under the tiles: a grape-700 icon, its line +// and a chevron, linking to the screen it is about. +function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) { + return ( +
  • + + + {children} + + +
  • + ) +} + +// StatusRows are the rows that apply: Side quests waiting in the deck, and +// a Supply run ready. With neither, there are no rows. +function StatusRows({ home }: { home: Home }) { + const count = home.sideQuestCount + const list = home.shoppingList + if (count === 0 && !list.ready) return null + return ( +
      + {count > 0 && ( + + {`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`} + + )} + {list.ready && ( + + {`Shopping list ready · ${list.dates}`} + + )} +
    + ) +} + +// Tiles are the two shortcuts, Quick dinner and Cook from what I have. +function Tiles() { + return ( +
    + + + Quick dinner + + + + Cook from what I have + +
    + ) +} + +// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the +// Recent updates bell, the Tonight card (or the way to plan the week), the +// Quick dinner and Cook from what I have tiles, and the status rows, all +// from GET /api/v1/home. +// +// The bell only shows whether there are unread updates; the Recent updates +// sheet it opens is still to come. +export function HomePage() { + const home = useHome() + const data = home.data + return ( +
    +
    +
    + {data !== undefined ? ( +

    {data.dateLabel}

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

    Tonight's quest

    +
    + + + {data?.unread && } + +
    + {home.isPending && } + {home.isError && ( + void home.refetch()}>Couldn't load tonight's quest. {home.error.message} + )} + {data !== undefined && (data.tonight === null ? : )} + + {home.isPending && ( + + )} + {data !== undefined && } +
    + ) +} diff --git a/web/src/home/api.ts b/web/src/home/api.ts new file mode 100644 index 0000000..fb836cc --- /dev/null +++ b/web/src/home/api.ts @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { oneOf, photoId } from '../dinners/api.ts' +import { weekLabel } from '../plan/week.ts' + +export const HOME_PATH = '/api/v1/home' +export const HOME_KEY = ['home'] 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"; 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 + 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 (!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, + tonight: parseTonight(where, body.tonight), + sideQuestCount: body.sideQuestCount as number, + shoppingList: parseShoppingList(where, body.shoppingList), + unread: updates.unread, + } +} + +// useHome is the Home screen's summary (GET /api/v1/home). +export function useHome() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: HOME_KEY, + queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch(HOME_PATH)), + }) +} diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx index 55ec2a4..9876fe7 100644 --- a/web/src/onboarding/NotificationsStep.test.tsx +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts' import App from '../App.tsx' import { AuthBootstrap } from '../AuthBootstrap.tsx' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' @@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V const path = String(input) const method = init?.method ?? 'GET' if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME)) if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey()) if (method === 'POST' && path === '/api/v1/push/subscriptions') { subscriptions.push(JSON.parse(String(init?.body))) @@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) { } /> - Home} /> + Tonight's quest} /> Regulars step} /> @@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType) { await vi.waitFor(() => expect(server.held).toHaveLength(1)) expect(server.held[0]!.body).toEqual({ onboarding_completed: true }) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() } function useIphoneSafariTab() { @@ -210,7 +211,7 @@ describe('onboarding step 3', () => { expect(later()).toBeDisabled() completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() expect(requestPermission).not.toHaveBeenCalled() }) @@ -314,7 +315,7 @@ describe('onboarding step 3', () => { await vi.waitFor(() => expect(server.held).toHaveLength(1)) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(browser.notification.requestPermission).not.toHaveBeenCalled() expect(browser.pushManager.subscribe).not.toHaveBeenCalled() expect(server.subscriptions).toEqual([]) @@ -358,7 +359,7 @@ describe('onboarding step 3', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() await vi.waitFor(() => expect(server.held).toHaveLength(2)) completed(server.held[1]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() }) }) diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 4405812..caeaaa6 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -61,6 +61,17 @@ 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', + 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 = { -- 2.55.0 From 837da07f3e392c274a3d2b6c4f6681e47c1b4f1a Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Sat, 10 Oct 2026 15:48:26 -0400 Subject: [PATCH 3/5] Recent updates sheet (Feature 21) (#176) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- docs/ARCHITECTURE.md | 25 +- internal/db/notifications.sql.go | 47 ++++ internal/db/querier.go | 11 + internal/db/settings.sql.go | 17 ++ internal/home/home.go | 51 ++++ internal/home/home_test.go | 203 +++++++++++++- internal/household/settings.go | 16 ++ internal/notify/notify.go | 39 +++ internal/server/home.go | 63 +++++ internal/server/home_test.go | 175 ++++++++++++ internal/server/server.go | 5 +- internal/server/server_test.go | 16 +- migrations/queries/notifications.sql | 17 ++ migrations/queries/settings.sql | 8 + web/src/home/HomePage.tsx | 14 +- web/src/home/UpdatesSheet.module.css | 49 ++++ web/src/home/UpdatesSheet.test.tsx | 381 +++++++++++++++++++++++++++ web/src/home/UpdatesSheet.tsx | 117 ++++++++ web/src/home/api.test.ts | 39 +++ web/src/home/api.ts | 104 +++++++- 20 files changed, 1387 insertions(+), 10 deletions(-) create mode 100644 web/src/home/UpdatesSheet.module.css create mode 100644 web/src/home/UpdatesSheet.test.tsx create mode 100644 web/src/home/UpdatesSheet.tsx create mode 100644 web/src/home/api.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07e3b92..27e3e46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1115,7 +1115,30 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. 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. Nothing writes `updates_seen_at` yet. + 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", "created_at": "2026-10-09T14:00:00.000Z"}]} + ``` + + Every field comes from the stored payload, which never holds a webhook + URL or secret; `url` is the same-origin path the event opens. +- `POST /api/v1/updates/seen` with `{"through": ""}` (CSRF + header required) records that the household has seen its updates + through the newest one it was shown, and answers 204. The last-seen + instant is `household_settings.updates_seen_at` — the one place it is + stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older + than the stored one is accepted and changes nothing, and an event + stored after `through` keeps `unread` true. It is not a settings edit: + `updated_by` and `updated_at` are left as they are and settings + subscribers are not told. 400 `invalid_request` for a body that is not + exactly that object, a `through` that is not RFC 3339, or one after + now. ## Frontend diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go index 3758e5d..94c1dd6 100644 --- a/internal/db/notifications.sql.go +++ b/internal/db/notifications.sql.go @@ -182,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref return items, nil } +const listRecentNotifications = `-- name: ListRecentNotifications :many +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT ?1 +` + +type ListRecentNotificationsRow struct { + EventKey string + Payload string + CreatedAt string +} + +// Returns the last lim distinct events stored, newest first: one row per +// event key, the last stored for it whatever its channel, so an event +// stored for push and Google Chat is one row. The last, because a channel +// switched on later can store the same event key again, and +// HasNotificationSince counts that row: an event is as new as its newest +// row, so marking it seen also covers that row. +func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRecentNotificationsRow + for rows.Next() { + var i ListRecentNotificationsRow + if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markNotificationSent = `-- name: MarkNotificationSent :execrows UPDATE notification_outbox SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1 diff --git a/internal/db/querier.go b/internal/db/querier.go index 2213f67..6a11650 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -212,6 +212,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 +261,10 @@ type Querier interface { // Records list id sent to Google Chat at sent_to_chat_at, its send // confirmed, unless it is frozen into the Supply archive. MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) + // Moves updates_seen_at forward to seen_at; affects no row when it is + // already at or after seen_at, so it never moves back. It is not a settings + // edit: updated_by and updated_at are left as they are. + MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 04e9ec7..65f68dc 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows +UPDATE household_settings +SET updates_seen_at = CAST(?1 AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT)) +` + +// Moves updates_seen_at forward to seen_at; affects no row when it is +// already at or after seen_at, so it never moves back. It is not a settings +// edit: updated_by and updated_at are left as they are. +func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) { + result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const recordChatTest = `-- name: RecordChatTest :execrows UPDATE chat_settings SET last_test_at = ?, last_test_status = ?, updated_at = ? diff --git a/internal/home/home.go b/internal/home/home.go index 73f21fb..735ab40 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -195,3 +195,54 @@ func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) { } return unread, nil } + +// RecentLimit is how many events the Recent updates sheet lists. +const RecentLimit = 10 + +// Update is one event in the Recent updates sheet. +type Update struct { + // Type is the event type, e.g. plan_ready. + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + // URL is the path-absolute, same-origin path a tap on the update opens. + URL string `json:"url"` + // CreatedAt is when the event was stored, UTC RFC 3339 with + // milliseconds; it is what POST /updates/seen takes back. + CreatedAt string `json:"created_at"` +} + +// Updates returns the last RecentLimit distinct notification events, newest +// first, whatever channels they went to. +func (s *Service) Updates(ctx context.Context) ([]Update, error) { + events, err := s.notifier.Recent(ctx, RecentLimit) + if err != nil { + return nil, fmt.Errorf("home: %w", err) + } + out := make([]Update, len(events)) + for i, ev := range events { + out[i] = Update{ + Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL, + CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(), + } + } + return out, nil +} + +// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now. +var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now") + +// MarkUpdatesSeen records that the household has seen its updates through +// through, the created_at of the newest update it was shown, so +// recentUpdates.unread is false until an event is stored after it. A time +// before the one already recorded changes nothing; one after now is +// ErrSeenInFuture, since it would hide events not yet stored. +func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error { + if through.After(s.now()) { + return ErrSeenInFuture + } + if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil { + return fmt.Errorf("home: %w", err) + } + return nil +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go index 4742350..fb53d7e 100644 --- a/internal/home/home_test.go +++ b/internal/home/home_test.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "encoding/base64" + "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" @@ -43,6 +45,7 @@ var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", type env struct { svc *home.Service st *store.Store + settings *household.SettingsService notifier *notify.Service loc *time.Location } @@ -114,7 +117,8 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - svc, err := home.NewService(household.NewSettingsService(st), plans, suggestSvc, shop, notifier) + settings := household.NewSettingsService(st) + svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier) if err != nil { t.Fatal(err) } @@ -122,7 +126,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - return &env{svc: svc, st: st, notifier: notifier, loc: loc} + 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 @@ -370,3 +374,198 @@ func TestNewServiceRequiresServices(t *testing.T) { t.Fatal("NewService accepted nil services") } } + +// store stores event key of type typ in the outbox at instant at, for each +// of channels, as notify stores an event. +func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } +} + +// Updates are the last ten distinct events, newest first: an event stored +// for push and Google Chat is listed once, whatever became of its delivery. +func TestUpdates(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if got == nil || len(got) != 0 { + t.Errorf("updates with no notifications = %#v, want an empty list", got) + } + + base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + for i := range 12 { + key := fmt.Sprintf("plan_ready:%02d", i) + e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat) + } + // A delivered and a failed row of one event are still one event. + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil { + t.Fatal(err) + } + + got, err = e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != home.RecentLimit { + t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got) + } + for i, u := range got { + n := 11 - i + key := fmt.Sprintf("plan_ready:%02d", n) + want := home.Update{ + Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, + CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(), + } + if u != want { + t.Errorf("updates[%d] = %+v, want %+v", i, u, want) + } + } +} + +// An event emitted to push and Google Chat is one update. +func TestUpdatesEmittedToTwoChannels(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil { + t.Fatal(err) + } + var rows int + if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 2 { + t.Fatalf("outbox rows = %d, want push and chat", rows) + } + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" { + t.Errorf("updates = %+v, want the one plan_ready event", got) + } +} + +// Marking updates seen through the newest clears unread until a newer event +// is stored; marking them seen through an older time does not move the +// mark back, and a time after now is refused. +func TestMarkUpdatesSeen(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + unread := func() bool { + t.Helper() + return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread + } + first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC) + e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush) + if !unread() { + t.Fatal("not unread with an event never seen") + } + // The settings were last edited well before: marking updates seen must + // keep that time and tell no settings subscriber. + edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil { + t.Fatal(err) + } + var notified int + unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ }) + defer unsubscribe() + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newest event seen") + } + + second := first.Add(time.Millisecond) + e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat) + if !unread() { + t.Error("not unread with an event stored after the mark") + } + // Seen again through the older event, as a sheet opened before the + // newer one arrived would: nothing moves, so it stays unread. + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if !unread() { + t.Error("an older mark hid the newer event") + } + if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newer event seen") + } + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + settings, err := e.settings.Get(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want { + t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want) + } + if settings.UpdatedBy != nil { + t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy) + } + if settings.UpdatedAt != edited { + t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited) + } + if notified != 0 { + t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified) + } + + // The clock reads 17:00 UTC. + if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) { + t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err) + } +} + +// An event stored again for a channel switched on later is as new as that +// row: the update shows its time, and marking it seen clears unread. +func TestUpdateStoredAgainForAnotherChannel(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) + later := first.Add(30 * time.Minute) + e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush) + e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat) + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want { + t.Fatalf("updates = %+v, want the one event at %s", got, want) + } + e.at(t, "2026-10-13 12:00") + if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil { + t.Fatal(err) + } + if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread { + t.Error("unread after the event was marked seen through its listed time") + } +} diff --git a/internal/household/settings.go b/internal/household/settings.go index 88b8586..f7fc55e 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "sync" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat return settings, nil } +// MarkUpdatesSeen records that the household has seen its Recent updates +// through seenAt: updates_seen_at moves forward to seenAt, and is left as it +// is when it is already at or after it. It is not a settings edit, so +// updated_by and updated_at are kept and subscribers are not told. +func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error { + if seenAt.IsZero() { + return errors.New("household: mark updates seen: zero time") + } + _, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String()) + if err != nil { + return fmt.Errorf("household: mark updates seen: %w", err) + } + return nil +} + // applyPatch applies a validated patch to the household settings row in q's // transaction, recording updatedBy, and returns the new row. func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index ae73a5a..bc4503b 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -205,6 +205,45 @@ func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, erro return found, nil } +// Stored is one event as it was stored, whatever channels it went to. +type Stored struct { + Type EventType + Title string + Body string + // URL is the path-absolute, same-origin path the event opens. + URL string + // CreatedAt is when the event was last stored, for any channel: the + // instant StoredSince must be given for it not to count. + CreatedAt time.Time +} + +// Recent returns the last n distinct events stored, newest first. An event +// stored for more than one channel is one event, as new as its newest row; +// one whose preference switches every channel off is never stored and +// never listed. +func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) { + if n <= 0 { + return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n) + } + rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n)) + if err != nil { + return nil, fmt.Errorf("notify: list recent events: %w", err) + } + out := make([]Stored, 0, len(rows)) + for _, r := range rows { + var p Payload + if err := json.Unmarshal([]byte(r.Payload), &p); err != nil { + return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err) + } + created, err := parseTimestamp(r.CreatedAt) + if err != nil { + return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err) + } + out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created}) + } + return out, nil +} + // emitTx stores ev in tx for each channel that channels chooses from the // preference of ev's type. func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error { diff --git a/internal/server/home.go b/internal/server/home.go index f54e7fb..92377fe 100644 --- a/internal/server/home.go +++ b/internal/server/home.go @@ -1,8 +1,10 @@ package server import ( + "errors" "log/slog" "net/http" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" ) @@ -20,3 +22,64 @@ func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc { writeJSON(w, r, logger, http.StatusOK, h) } } + +// updatesBody is the GET /updates answer. +type updatesBody struct { + Updates []home.Update `json:"updates"` +} + +// getUpdates answers GET /updates with the Recent updates: the last +// home.RecentLimit distinct notification events, newest first. +func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + updates, err := svc.Updates(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read recent updates", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates}) + } +} + +// maxUpdatesSeenBody bounds a POST /updates/seen request body. +const maxUpdatesSeenBody = 1 << 10 + +// updatesSeenRequest is the POST /updates/seen body: through is the +// created_at of the newest update the household was shown. +type updatesSeenRequest struct { + Through *string `json:"through"` +} + +// postUpdatesSeen answers POST /updates/seen by recording that the +// household has seen its updates through the body's through, and answers +// 204. A through before the one already recorded changes nothing. +func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Through == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required") + return + } + through, err := time.Parse(time.RFC3339Nano, *req.Through) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp") + return + } + err = svc.MarkUpdatesSeen(r.Context(), through) + if errors.Is(err, home.ErrSeenInFuture) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now") + return + } + if err != nil { + logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/server/home_test.go b/internal/server/home_test.go index f84d5ff..9f98be7 100644 --- a/internal/server/home_test.go +++ b/internal/server/home_test.go @@ -1,13 +1,18 @@ 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 @@ -67,3 +72,173 @@ func TestGetHomeNeedsSession(t *testing.T) { t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code) } } + +// homeRouter returns a router whose Home reads st, and st. +func homeRouter(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + svc := newTestHomeOn(t, st) + return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st +} + +// storeEvent stores event key in st's outbox at instant at for each of +// channels, as notify stores an event, and returns at as stored. +func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } + return ts +} + +// unread reads recentUpdates.unread from GET /home. +func unread(t *testing.T, h http.Handler) bool { + t.Helper() + rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body) + } + updates, ok := body["recentUpdates"].(map[string]any) + if !ok { + t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body) + } + return updates["unread"] == true +} + +// markSeen posts body to /updates/seen and returns the answer. +func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))) + return rec +} + +// GET /updates lists the last distinct events newest first, one per event +// whatever its channels, in their JSON shape. +func TestGetUpdates(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` { + t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body) + } + + now := time.Now() + older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush) + newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat) + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + want := `{"updates":[` + + `{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` + + `{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want) + } +} + +// Unread is true while an event newer than the last seen one exists, +// clears once POST /updates/seen marks the newest seen, and returns when a +// newer event is stored; marking an older one seen again moves nothing. +func TestUpdatesSeen(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + now := time.Now() + + if unread(t, h) { + t.Error("unread with no events") + } + first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat) + if !unread(t, h) { + t.Error("not unread with an event never seen") + } + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newest event was marked seen") + } + + second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush) + if !unread(t, h) { + t.Error("not unread with an event newer than the last seen") + } + // Already seen: marking the older event seen again is accepted and + // changes nothing. + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body) + } + if !unread(t, h) { + t.Error("marking an older event seen hid a newer one") + } + if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newer event was marked seen") + } +} + +// A malformed POST /updates/seen is 400 and records nothing. +func TestUpdatesSeenRejectsMalformed(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush) + + for _, body := range []string{ + ``, + `[]`, + `{}`, + `{"through":null}`, + `{"through":42}`, + `{"through":"yesterday"}`, + `{"through":"2026-10-13 12:00:00"}`, + `{"through":"2026-10-13T12:00:00.000Z","extra":true}`, + `{"through":"2026-10-13T12:00:00.000Z"} {}`, + `{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`, + } { + rec := markSeen(t, h, body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) { + t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body) + } + } + if !unread(t, h) { + t.Error("a malformed POST /updates/seen marked updates seen") + } +} + +// Both endpoints need a session, and POST /updates/seen the CSRF token. +func TestUpdatesNeedSessionAndCSRF(t *testing.T) { + t.Parallel() + h, _ := homeRouter(t) + body := `{"through":"2026-10-01T12:00:00.000Z"}` + for _, req := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)), + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code) + } + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c5f5850..3b0352e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,7 +67,8 @@ 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; homeSvc serves GET /api/v1/home; 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. @@ -142,6 +143,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a } apiV1.Get("/me", me(logger)) apiV1.Get("/home", getHome(logger, homeSvc)) + apiV1.Get("/updates", getUpdates(logger, homeSvc)) + apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2ea9637..6a1769d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -52,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), newTestHome(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) } @@ -265,7 +271,13 @@ func newTestShopping(t *testing.T) *shopping.Service { // connection. One database keeps the routers that never ask for Home cheap. func newTestHome(t *testing.T) *home.Service { t.Helper() - st := newTestStore(t) + 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) } diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql index 83d0ac9..d952567 100644 --- a/migrations/queries/notifications.sql +++ b/migrations/queries/notifications.sql @@ -74,3 +74,20 @@ SELECT EXISTS ( SELECT 1 FROM notification_outbox WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT) ) AS found; + +-- name: ListRecentNotifications :many +-- Returns the last lim distinct events stored, newest first: one row per +-- event key, the last stored for it whatever its channel, so an event +-- stored for push and Google Chat is one row. The last, because a channel +-- switched on later can store the same event key again, and +-- HasNotificationSince counts that row: an event is as new as its newest +-- row, so marking it seen also covers that row. +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT sqlc.arg(lim); diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index f40a0d7..eb9ec9d 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -154,3 +154,11 @@ RETURNING *; UPDATE chat_settings SET last_test_at = ?, last_test_status = ?, updated_at = ? WHERE id = 1 AND webhook_url_ct = ?; + +-- name: MarkUpdatesSeen :execrows +-- Moves updates_seen_at forward to seen_at; affects no row when it is +-- already at or after seen_at, so it never moves back. It is not a settings +-- edit: updated_by and updated_at are left as they are. +UPDATE household_settings +SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT)); diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx index fe88894..81c3196 100644 --- a/web/src/home/HomePage.tsx +++ b/web/src/home/HomePage.tsx @@ -1,4 +1,5 @@ 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' @@ -11,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { useHome, type Home, type Origin, type Tonight } from './api.ts' import styles from './HomePage.module.css' +import { UpdatesSheet } from './UpdatesSheet.tsx' const ORIGIN_LABEL: Record = { regular: 'Regular', side_quest: 'Side quest' } @@ -108,11 +110,12 @@ function Tiles() { // Quick dinner and Cook from what I have tiles, and the status rows, all // from GET /api/v1/home. // -// The bell only shows whether there are unread updates; the Recent updates -// sheet it opens is still to come. +// 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 (
    @@ -124,7 +127,11 @@ export function HomePage() { )}

    Tonight's quest

    - + setUpdatesOpen(true)} + > {data?.unread && } @@ -142,6 +149,7 @@ export function HomePage() { )} {data !== undefined && } + setUpdatesOpen(false)} /> ) } diff --git a/web/src/home/UpdatesSheet.module.css b/web/src/home/UpdatesSheet.module.css new file mode 100644 index 0000000..14c7eb0 --- /dev/null +++ b/web/src/home/UpdatesSheet.module.css @@ -0,0 +1,49 @@ +/* The Recent updates sheet the Home bell opens. */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.icon { + color: var(--color-accent-700); +} + +.time { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); + white-space: nowrap; +} + +.skeletonRow { + height: 56px; + border-radius: var(--radius-md); +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/home/UpdatesSheet.test.tsx b/web/src/home/UpdatesSheet.test.tsx new file mode 100644 index 0000000..aba8a02 --- /dev/null +++ b/web/src/home/UpdatesSheet.test.tsx @@ -0,0 +1,381 @@ +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 { 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', + created_at: minutesAgo(5), + }, + { type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) }, + { + type: 'list_ready', + title: 'Supply run ready', + body: 'Week 41 · Oct 5 – 11', + url: '/plan/shopping?week=2026-10-05', + created_at: minutesAgo(2 * 24 * 60), + }, + ], +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. Home is unread until the updates are marked +// seen, as the server answers. +function stubServer(responders: Record = {}) { + let seen = false + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }), + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES), + [`POST ${UPDATES_SEEN_PATH}`]: () => { + seen = true + return new Response(null, { status: 204 }) + }, + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// posts lists the bodies of the POST /updates/seen requests sent, each of +// which must carry the CSRF token. +function posts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH) + .map(([, init]) => { + expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + return String(init?.body) + }) +} + +function homeReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET') + .length +} + +function updatesReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter( + ([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET', + ).length +} + +// Where shows the route a row went to. +function Where() { + const location = useLocation() + return

    {location.pathname + location.search}

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

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

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

    + Recent updates +

    + +
    +
    + ) +} diff --git a/web/src/home/api.test.ts b/web/src/home/api.test.ts new file mode 100644 index 0000000..d4321c7 --- /dev/null +++ b/web/src/home/api.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { 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`, + ) + }) +}) diff --git a/web/src/home/api.ts b/web/src/home/api.ts index fb836cc..71625f5 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -1,11 +1,15 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { oneOf, photoId } from '../dinners/api.ts' import { weekLabel } from '../plan/week.ts' +import { 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] @@ -95,3 +99,101 @@ export function useHome() { queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch(HOME_PATH)), }) } + +export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const +export type UpdateType = (typeof UPDATE_TYPES)[number] + +// Update is one of the Recent updates: a notification event, whatever +// channels it went to. url is the same-origin path it opens; created_at is +// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen +// takes back. +export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string } + +// parseUpdate checks one update. Its url must stay on origin once parsed, as +// the server only ever stores a path on the app's own origin; url is that +// path as parsed. +function parseUpdate(where: string, body: unknown, origin: string): Update { + if ( + !isRecord(body) || + typeof body.title !== 'string' || + typeof body.body !== 'string' || + typeof body.url !== 'string' || + typeof body.created_at !== 'string' + ) { + throw new Error(`${where}: an update is not a notification event`) + } + const url = appPath(body.url, origin) + if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`) + if (Number.isNaN(Date.parse(body.created_at))) { + throw new Error(`${where}: update created_at "${body.created_at}" is not a time`) + } + return { + type: oneOf(where, 'type', UPDATE_TYPES, body.type), + title: body.title, + body: body.body, + url, + created_at: body.created_at, + } +} + +// parseUpdates checks a GET /api/v1/updates body, from the app at origin: +// the updates, newest first. +export function parseUpdates(where: string, body: unknown, origin: string): Update[] { + if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`) + return body.updates.map((update) => parseUpdate(where, update, origin)) +} + +// useUpdates is the Recent updates (GET /api/v1/updates): the last ten +// notification events, newest first. Each opening of the sheet reads it +// afresh (refetchOnMount 'always'), whatever the query client still holds; +// the sheet shows, and marks seen, only the list that opening read. Closing +// the sheet aborts a read still in flight, so a reopen sends its own rather +// than adopt one sent before it. +export function useUpdates() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: UPDATES_KEY, + queryFn: async ({ signal }) => + parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH, { signal }), window.location.origin), + refetchOnMount: 'always', + }) +} + +const MARK_SEEN_KEY = ['updates', 'seen'] as const + +// useMarkUpdatesSeen records that the household has seen its updates +// through the created_at of the newest one shown (POST +// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home +// read in flight is stopped so it cannot bring it back; if the server +// refuses, the dot is put back as it was before the first of the marks in +// flight. Once the last mark in flight settles Home is read again, so the +// dot shows only for an event stored since. +export function useMarkUpdatesSeen() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + // alone says whether this is the only mark in flight; each counts itself + // until it has settled. + const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1 + return useMutation({ + mutationKey: MARK_SEEN_KEY, + mutationFn: async (through: string) => { + await apiFetch(UPDATES_SEEN_PATH, { method: 'POST', body: { through } }) + }, + onMutate: async () => { + // A mark sent while another is in flight would snapshot that one's + // optimistic answer, so only the first snapshots. + const unread = alone() ? queryClient.getQueryData(HOME_KEY)?.unread : undefined + await queryClient.cancelQueries({ queryKey: HOME_KEY }) + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread: false }) + return { unread } + }, + onError: (_error, _through, context) => { + if (context?.unread === undefined || !alone()) return + const { unread } = context + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread }) + }, + onSettled: () => { + if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY }) + }, + }) +} -- 2.55.0 From 62f4f9eecedd29b64734a9ece534ea79b6b746d4 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 17:13:20 -0400 Subject: [PATCH 4/5] Keep it in rotation card (Feature 21) (#179) Co-authored-by: dev_agent --- web/src/App.test.tsx | 12 +- web/src/home/HomePage.test.tsx | 11 +- web/src/home/HomePage.tsx | 5 +- web/src/home/RotationCheck.module.css | 79 ++++++++ web/src/home/RotationCheck.test.tsx | 281 ++++++++++++++++++++++++++ web/src/home/RotationCheck.tsx | 79 ++++++++ web/src/home/UpdatesSheet.test.tsx | 2 + web/src/home/rotation.ts | 107 ++++++++++ web/src/push/notification.test.ts | 14 ++ web/src/push/notification.ts | 16 +- 10 files changed, 598 insertions(+), 8 deletions(-) create mode 100644 web/src/home/RotationCheck.module.css create mode 100644 web/src/home/RotationCheck.test.tsx create mode 100644 web/src/home/RotationCheck.tsx create mode 100644 web/src/home/rotation.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f73ad84..e36d16f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -88,7 +88,7 @@ describe('App routes', () => { it('/ renders the Home screen inside the shell', async () => { mockViewport(390) - stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) }) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) }) renderAt('/') const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" }) expect(screen.getByRole('main')).toContainElement(heading) @@ -273,7 +273,13 @@ describe('App bootstrap', () => { it('reports a /me failure with a retry instead of rendering the routes', async () => { mockViewport(390) - stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME)) + const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)] + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/me': () => mes.shift()!, + '/api/v1/home': () => jsonResponse(200, HOME), + '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }), + }) renderAt('/') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') @@ -294,7 +300,7 @@ describe('App offline bootstrap', () => { // visitOnline is an earlier online visit on this device: /me answers with a // member, then the app is closed. async function visitOnline() { - stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) }) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) }) const { unmount } = renderAt('/') expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() unmount() diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx index 5a2f838..490c166 100644 --- a/web/src/home/HomePage.test.tsx +++ b/web/src/home/HomePage.test.tsx @@ -3,10 +3,11 @@ import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProviders, HOME } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' -import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts' +import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { HOME_PATH } from './api.ts' import { HomePage } from './HomePage.tsx' +import { ROTATION_REVIEWS_PATH } from './rotation.ts' // NOTHING_YET is the home of a household with no plan, no Side quests, no // Supply run and nothing unread. @@ -29,7 +30,7 @@ function Where() { } function renderHome(body: unknown) { - stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) }) + stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) }) return renderRouter() } @@ -198,7 +199,11 @@ describe('HomePage', () => { }) it('reports a failure with Retry, and no made-up content', async () => { - stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME)) + const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)] + stubFetchByPath({ + [HOME_PATH]: () => homes.shift()!, + [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }), + }) renderRouter() expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message") diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx index 81c3196..2ceead1 100644 --- a/web/src/home/HomePage.tsx +++ b/web/src/home/HomePage.tsx @@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { useHome, type Home, type Origin, type Tonight } from './api.ts' import styles from './HomePage.module.css' +import { RotationCheck } from './RotationCheck.tsx' import { UpdatesSheet } from './UpdatesSheet.tsx' const ORIGIN_LABEL: Record = { regular: 'Regular', side_quest: 'Side quest' } @@ -108,7 +109,8 @@ function Tiles() { // HomePage is mock 1g, Home: today's date over "Tonight's quest" and the // Recent updates bell, the Tonight card (or the way to plan the week), the // Quick dinner and Cook from what I have tiles, and the status rows, all -// from GET /api/v1/home. +// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in +// rotation?", while a tried dinner waits for the household's answer. // // The bell carries a grape dot while there are unread updates and opens the // Recent updates sheet, which marks them seen. @@ -141,6 +143,7 @@ export function HomePage() { void home.refetch()}>Couldn't load tonight's quest. {home.error.message} )} {data !== undefined && (data.tonight === null ? : )} + {home.isPending && (