Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #181.
This commit is contained in:
+7
-1
@@ -23,6 +23,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -177,6 +178,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,7 +227,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+62
-5
@@ -800,9 +800,11 @@ failed run is not retried. A claim that fails is not skipped: the pass is
|
||||
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
|
||||
change is recorded with the instant it was saved, and fire times up to that
|
||||
instant follow the old rule and later ones the new, however late the
|
||||
scheduler applies it. At boot only each schedule's most recent fire
|
||||
time is considered: it runs if it passed within 24 hours, and is otherwise
|
||||
claimed without running (logged as missed) so it never runs later. DST
|
||||
scheduler applies it. At boot, and whenever the scheduler wakes late,
|
||||
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
|
||||
processed oldest first, each running once. A most recent fire time that
|
||||
passed longer ago is claimed without running (logged as missed) so it
|
||||
never runs later; older ones are not looked at. DST
|
||||
follows `household.LocalTime`: a skipped time fires later by the gap, a
|
||||
repeated time fires once, at its first occurrence.
|
||||
|
||||
@@ -870,14 +872,14 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||
change and its notification commit together or not at all:
|
||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job, asked for
|
||||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
||||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
||||
week. Only the first draft of a week notifies: a
|
||||
redraft (Rebuild week) has the same key, and a manual swap never
|
||||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||||
or "The plan for the week of Oct 26") and its body the drafted
|
||||
dinners Monday to Sunday.
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||
after Push to Calendar; remaking it does not notify.
|
||||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
||||
@@ -1184,6 +1186,61 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Home API (Feature 21)
|
||||
|
||||
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||||
services that own each value, at "now" in the household's zone (never
|
||||
UTC), and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateLabel": "Friday, Oct 2",
|
||||
"timezone": "America/Chicago",
|
||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||
"sideQuestCount": 10,
|
||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||
"recentUpdates": {"unread": true}
|
||||
}
|
||||
```
|
||||
|
||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||
when the week has no plan; it changes at the household's local
|
||||
midnight, so the PWA reads Home again at the next local midnight of
|
||||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
||||
null when unknown. `sideQuestCount` is the pending cards of the deck,
|
||||
read without writing (`suggest.PendingCount`): a Home read never fails
|
||||
orphaned batches the way a `suggest.Deck` read does.
|
||||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||
the current week's (`shopping.Get`); with neither, it is the current
|
||||
week with `ready` false. `recentUpdates.unread` is true when a
|
||||
notification was stored in `notification_outbox` after
|
||||
`household_settings.updates_seen_at`, or at all while that is null
|
||||
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||
never stored and never counts.
|
||||
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
|
||||
answers the Recent updates sheet: the last 10 distinct events in
|
||||
`notification_outbox`, newest first, one per event key whatever its
|
||||
channels or delivery status (an event stored for push and Google Chat is
|
||||
one update):
|
||||
|
||||
```json
|
||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||
```
|
||||
|
||||
Every field comes from the stored payload, which never holds a webhook
|
||||
URL or secret; `url` is the same-origin path the event opens.
|
||||
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
|
||||
header required) records that the household has seen its updates
|
||||
through the newest one it was shown, and answers 204. The last-seen
|
||||
instant is `household_settings.updates_seen_at` — the one place it is
|
||||
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
|
||||
than the stored one is accepted and changes nothing, and an event
|
||||
stored after `through` keeps `unread` true. It is not a settings edit:
|
||||
`updated_by` and `updated_at` are left as they are and settings
|
||||
subscribers are not told. 400 `invalid_request` for a body that is not
|
||||
exactly that object, a `through` that is not RFC 3339, or one after
|
||||
now.
|
||||
|
||||
## Frontend
|
||||
|
||||
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||
|
||||
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
// The cards' "Open" buttons link under the public URL to the event's week.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: planTitle(week, thisWeek),
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
@@ -49,7 +49,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
@@ -59,7 +59,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for next week",
|
||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "Next week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||
@@ -69,7 +69,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for a later week",
|
||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||
key: "plan_ready:2026-10-26",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-26",
|
||||
week: "2026-10-26",
|
||||
title: "The plan for the week of Oct 26 is ready",
|
||||
body: "Tacos",
|
||||
@@ -79,7 +79,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
@@ -89,7 +89,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
|
||||
@@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNotificationSince = `-- name: HasNotificationSince :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
|
||||
) AS found
|
||||
`
|
||||
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
|
||||
var found bool
|
||||
err := row.Scan(&found)
|
||||
return found, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
@@ -166,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecentNotifications = `-- name: ListRecentNotifications :many
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT ?1
|
||||
`
|
||||
|
||||
type ListRecentNotificationsRow struct {
|
||||
EventKey string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListRecentNotificationsRow
|
||||
for rows.Next() {
|
||||
var i ListRecentNotificationsRow
|
||||
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
|
||||
@@ -182,6 +182,9 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
@@ -212,6 +215,13 @@ type Querier interface {
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -254,6 +264,10 @@ type Querier interface {
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||||
// swipe, the Supply run and whether there are recent updates. It owns no
|
||||
// data: every value is read through the service that owns it, and "now"
|
||||
// and "tonight" are in the household's zone, never UTC.
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// Service reads the Home screen.
|
||||
type Service struct {
|
||||
settings *household.SettingsService
|
||||
plans *plan.Service
|
||||
suggest *suggest.Service
|
||||
shopping *shopping.Service
|
||||
notifier *notify.Service
|
||||
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service reading through the given services.
|
||||
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||||
switch {
|
||||
case settings == nil:
|
||||
return nil, errors.New("home: nil household settings service")
|
||||
case plans == nil:
|
||||
return nil, errors.New("home: nil plan service")
|
||||
case suggestSvc == nil:
|
||||
return nil, errors.New("home: nil suggestions service")
|
||||
case shop == nil:
|
||||
return nil, errors.New("home: nil shopping service")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("home: nil notifier")
|
||||
}
|
||||
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Home is the Home screen as the API shows it.
|
||||
type Home struct {
|
||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||
DateLabel string `json:"dateLabel"`
|
||||
// Timezone is the household's IANA zone, whose next local midnight
|
||||
// is when Home is read again for the new day.
|
||||
Timezone string `json:"timezone"`
|
||||
// Tonight is the dinner planned for today on the current week's plan;
|
||||
// nil when the week has no plan.
|
||||
Tonight *Dinner `json:"tonight"`
|
||||
// SideQuestCount is how many Side quests are pending on the deck.
|
||||
SideQuestCount int `json:"sideQuestCount"`
|
||||
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||||
// else of the current week.
|
||||
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||||
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||||
}
|
||||
|
||||
// Dinner is tonight's dinner.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
// Origin is regular or side_quest.
|
||||
Origin string `json:"origin"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
}
|
||||
|
||||
// ShoppingStatus is whether a week's Supply run is ready.
|
||||
type ShoppingStatus struct {
|
||||
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||||
WeekStart string `json:"weekStart"`
|
||||
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||||
Label string `json:"label"`
|
||||
// Ready is true when the week has a Supply run.
|
||||
Ready bool `json:"ready"`
|
||||
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||||
// until it is, and when there is no Supply run.
|
||||
SentToChatAt *string `json:"sentToChatAt"`
|
||||
}
|
||||
|
||||
// RecentUpdates says whether there is something new for the bell.
|
||||
type RecentUpdates struct {
|
||||
// Unread is true when a notification was stored after the household
|
||||
// last saw its updates (household_settings.updates_seen_at), or at all
|
||||
// when it never has.
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
// Get reads the Home screen at the service's "now". It only reads: it
|
||||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||||
}
|
||||
now := s.now().In(zone)
|
||||
today := household.Today(now, zone)
|
||||
thisWeek := household.WeekStart(today)
|
||||
|
||||
tonight, err := s.tonight(ctx, thisWeek, today)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
sideQuests, err := s.suggest.PendingCount(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||
}
|
||||
list, err := s.shoppingStatus(ctx, thisWeek)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
return Home{
|
||||
DateLabel: now.Format("Monday, Jan 2"),
|
||||
Timezone: settings.Timezone,
|
||||
Tonight: tonight,
|
||||
SideQuestCount: sideQuests,
|
||||
ShoppingList: list,
|
||||
RecentUpdates: RecentUpdates{Unread: unread},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tonight returns the dinner planned on today in the plan of the week
|
||||
// starting thisWeek, or nil when the week has no plan.
|
||||
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||||
p, err := s.plans.Get(ctx, thisWeek)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: read plan: %w", err)
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == today.String() {
|
||||
return &Dinner{
|
||||
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||||
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||||
}
|
||||
|
||||
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||||
// it has one, else of the current week's, ready or not.
|
||||
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||||
y, m, d := thisWeek.Date()
|
||||
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||||
l, err := s.shopping.Get(ctx, week)
|
||||
if errors.Is(err, shopping.ErrListNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||||
}
|
||||
return weekStatus(week, true, l.SentToChatAt)
|
||||
}
|
||||
return weekStatus(thisWeek, false, nil)
|
||||
}
|
||||
|
||||
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||||
}
|
||||
|
||||
// unread reports whether a notification was stored after seenAt, the
|
||||
// stored updates_seen_at, or at all when it is nil.
|
||||
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||
var since *time.Time
|
||||
if seenAt != nil {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(*seenAt); err != nil {
|
||||
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||||
}
|
||||
since = &ts.Time
|
||||
}
|
||||
unread, err := s.notifier.StoredSince(ctx, since)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// RecentLimit is how many events the Recent updates sheet lists.
|
||||
const RecentLimit = 10
|
||||
|
||||
// Update is one event in the Recent updates sheet.
|
||||
type Update struct {
|
||||
// Type is the event type, e.g. plan_ready.
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||||
URL string `json:"url"`
|
||||
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||||
// milliseconds; it is what POST /updates/seen takes back.
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Updates returns the last RecentLimit distinct notification events, newest
|
||||
// first, whatever channels they went to.
|
||||
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||||
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
out := make([]Update, len(events))
|
||||
for i, ev := range events {
|
||||
out[i] = Update{
|
||||
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||||
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||||
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its updates through
|
||||
// through, the created_at of the newest update it was shown, so
|
||||
// recentUpdates.unread is false until an event is stored after it. A time
|
||||
// before the one already recorded changes nothing; one after now is
|
||||
// ErrSeenInFuture, since it would hide events not yet stored.
|
||||
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||||
if through.After(s.now()) {
|
||||
return ErrSeenInFuture
|
||||
}
|
||||
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||||
return fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package home_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// zone is the household's zone in these tests: not UTC, so a late local
|
||||
// evening is already the next day in UTC.
|
||||
const zone = "America/Chicago"
|
||||
|
||||
// weekDates is the week of 2026-10-12, Monday first.
|
||||
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
|
||||
type env struct {
|
||||
svc *home.Service
|
||||
st *store.Store
|
||||
settings *household.SettingsService
|
||||
notifier *notify.Service
|
||||
loc *time.Location
|
||||
}
|
||||
|
||||
// newEnv returns a home service over a migrated temp database whose
|
||||
// household is in zone, reading "now" from a clock set to at.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||||
googleSvc, err := gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
|
||||
CalendarAPI: google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||
}
|
||||
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := household.NewSettingsService(st)
|
||||
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
|
||||
}
|
||||
|
||||
// at reads the Home screen with the clock at the household-local wall
|
||||
// time "YYYY-MM-DD HH:MM".
|
||||
func (e *env) at(t *testing.T, local string) home.Home {
|
||||
t.Helper()
|
||||
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home.SetClock(e.svc, func() time.Time { return now })
|
||||
h, err := e.svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// dinner stores a dinner and returns its id.
|
||||
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
|
||||
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
|
||||
// unknown minutes. It returns the plan's id.
|
||||
func (e *env) planWeek(t *testing.T) string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range weekDates {
|
||||
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
|
||||
if date == "2026-10-14" {
|
||||
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return planID
|
||||
}
|
||||
|
||||
// Tonight's dinner and the date label follow the household's local
|
||||
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
|
||||
// Wednesday in UTC, and Home still shows Tuesday's dinner.
|
||||
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
local, label, dinner string
|
||||
}{
|
||||
// 23:59 CDT is 04:59 UTC the next day.
|
||||
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
|
||||
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
|
||||
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
|
||||
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
|
||||
} {
|
||||
h := e.at(t, tc.local)
|
||||
if h.DateLabel != tc.label {
|
||||
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
|
||||
}
|
||||
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
|
||||
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
|
||||
}
|
||||
}
|
||||
|
||||
// Late Sunday evening of the week before, the plan's week has not
|
||||
// begun locally, though in UTC it has.
|
||||
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
|
||||
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonight carries the dinner's id, name, photo, origin and minutes.
|
||||
func TestTonightDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||
}
|
||||
h = e.at(t, "2026-10-14 18:00")
|
||||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan, no suggestions, no Supply run and no notifications, Home
|
||||
// says so: no dinner tonight, nothing to swipe, the current week's Supply
|
||||
// run not ready and nothing unread.
|
||||
func TestHomeEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 23:59")
|
||||
want := home.Home{
|
||||
DateLabel: "Tuesday, Oct 13",
|
||||
Timezone: zone,
|
||||
ShoppingList: home.ShoppingStatus{
|
||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||
},
|
||||
}
|
||||
if h != want {
|
||||
t.Errorf("home = %+v, want %+v", h, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The Side quest count is the pending cards of the deck: a decided
|
||||
// suggestion is not counted.
|
||||
func TestSideQuestCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
|
||||
id := e.dinner(t, name, "suggested", "side_quest", 0)
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
|
||||
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
|
||||
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Home writes nothing: a batch still pending whose job is gone,
|
||||
// which a Deck read would fail, is left as it was, and the read succeeds.
|
||||
func TestGetDoesNotWriteBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
created := "2026-10-13T12:00:00.000Z"
|
||||
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Status != "pending" {
|
||||
t.Fatalf("new batch status = %q, want pending", batch.Status)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
|
||||
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
|
||||
}
|
||||
var status, updatedAt string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "pending" || updatedAt != created {
|
||||
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
// The Supply run shown is the upcoming week's when it has one, else the
|
||||
// current week's; the weeks are the household's local weeks.
|
||||
func TestShoppingStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
planID := e.planWeek(t)
|
||||
list := func(weekStart string, sent sql.NullString) {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
|
||||
store.NewID(), weekStart, planID, sent, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
list("2026-10-12", sql.NullString{})
|
||||
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
|
||||
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
|
||||
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
|
||||
}
|
||||
|
||||
sent := "2026-10-17T13:00:00.000Z"
|
||||
list("2026-10-19", sql.NullString{String: sent, Valid: true})
|
||||
h := e.at(t, "2026-10-16 09:00")
|
||||
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
|
||||
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
|
||||
}
|
||||
|
||||
// Late Sunday evening locally is Monday in UTC: the current week is
|
||||
// still the week of 2026-10-12, so its upcoming week's list shows.
|
||||
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
|
||||
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
|
||||
}
|
||||
// On Monday the week of 2026-10-19 is current and has no upcoming
|
||||
// list.
|
||||
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
|
||||
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
|
||||
}
|
||||
// A week later neither the current nor the upcoming week has a list.
|
||||
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
|
||||
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
|
||||
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent updates are unread when a notification was stored after the
|
||||
// household last saw its updates, or ever when it never has.
|
||||
func TestRecentUpdatesUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with no notifications")
|
||||
}
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification never seen")
|
||||
}
|
||||
seen := func(at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seen(time.Now().Add(time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with every notification seen")
|
||||
}
|
||||
seen(time.Now().Add(-time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification stored after updates were seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
|
||||
t.Fatal("NewService accepted nil services")
|
||||
}
|
||||
}
|
||||
|
||||
// store stores event key of type typ in the outbox at instant at, for each
|
||||
// of channels, as notify stores an event.
|
||||
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates are the last ten distinct events, newest first: an event stored
|
||||
// for push and Google Chat is listed once, whatever became of its delivery.
|
||||
func TestUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("updates with no notifications = %#v, want an empty list", got)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
for i := range 12 {
|
||||
key := fmt.Sprintf("plan_ready:%02d", i)
|
||||
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
}
|
||||
// A delivered and a failed row of one event are still one event.
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err = e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != home.RecentLimit {
|
||||
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
|
||||
}
|
||||
for i, u := range got {
|
||||
n := 11 - i
|
||||
key := fmt.Sprintf("plan_ready:%02d", n)
|
||||
want := home.Update{
|
||||
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
|
||||
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
|
||||
}
|
||||
if u != want {
|
||||
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An event emitted to push and Google Chat is one update.
|
||||
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("outbox rows = %d, want push and chat", rows)
|
||||
}
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
|
||||
t.Errorf("updates = %+v, want the one plan_ready event", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Marking updates seen through the newest clears unread until a newer event
|
||||
// is stored; marking them seen through an older time does not move the
|
||||
// mark back, and a time after now is refused.
|
||||
func TestMarkUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
unread := func() bool {
|
||||
t.Helper()
|
||||
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
|
||||
}
|
||||
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
|
||||
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
|
||||
if !unread() {
|
||||
t.Fatal("not unread with an event never seen")
|
||||
}
|
||||
// The settings were last edited well before: marking updates seen must
|
||||
// keep that time and tell no settings subscriber.
|
||||
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var notified int
|
||||
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
|
||||
defer unsubscribe()
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newest event seen")
|
||||
}
|
||||
|
||||
second := first.Add(time.Millisecond)
|
||||
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread() {
|
||||
t.Error("not unread with an event stored after the mark")
|
||||
}
|
||||
// Seen again through the older event, as a sheet opened before the
|
||||
// newer one arrived would: nothing moves, so it stays unread.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !unread() {
|
||||
t.Error("an older mark hid the newer event")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newer event seen")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := e.settings.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
|
||||
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
|
||||
}
|
||||
if settings.UpdatedBy != nil {
|
||||
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
|
||||
}
|
||||
if settings.UpdatedAt != edited {
|
||||
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
|
||||
}
|
||||
if notified != 0 {
|
||||
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
|
||||
}
|
||||
|
||||
// The clock reads 17:00 UTC.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
|
||||
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An event stored again for a channel switched on later is as new as that
|
||||
// row: the update shows its time, and marking it seen clears unread.
|
||||
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||
later := first.Add(30 * time.Minute)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
|
||||
t.Fatalf("updates = %+v, want the one event at %s", got, want)
|
||||
}
|
||||
e.at(t, "2026-10-13 12:00")
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
|
||||
t.Error("unread after the event was marked seen through its listed time")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -190,6 +190,61 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
||||
})
|
||||
}
|
||||
|
||||
// StoredSince reports whether a notification was stored after since, or
|
||||
// at all when since is nil. An event counts once it is stored for any
|
||||
// channel, whatever became of its delivery; one whose preference switches
|
||||
// every channel off is never stored and never counts.
|
||||
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
|
||||
var after sql.NullString
|
||||
if since != nil {
|
||||
after = sql.NullString{String: timestamp(*since), Valid: true}
|
||||
}
|
||||
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// Stored is one event as it was stored, whatever channels it went to.
|
||||
type Stored struct {
|
||||
Type EventType
|
||||
Title string
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path the event opens.
|
||||
URL string
|
||||
// CreatedAt is when the event was last stored, for any channel: the
|
||||
// instant StoredSince must be given for it not to count.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recent returns the last n distinct events stored, newest first. An event
|
||||
// stored for more than one channel is one event, as new as its newest row;
|
||||
// one whose preference switches every channel off is never stored and
|
||||
// never listed.
|
||||
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
|
||||
if n <= 0 {
|
||||
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
|
||||
}
|
||||
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: list recent events: %w", err)
|
||||
}
|
||||
out := make([]Stored, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var p Payload
|
||||
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
|
||||
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
|
||||
}
|
||||
created, err := parseTimestamp(r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
|
||||
}
|
||||
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||
// preference of ev's type.
|
||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||
|
||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
|
||||
// getHome answers GET /home with the Home screen at the household's local
|
||||
// "now".
|
||||
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read home", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
|
||||
// updatesBody is the GET /updates answer.
|
||||
type updatesBody struct {
|
||||
Updates []home.Update `json:"updates"`
|
||||
}
|
||||
|
||||
// getUpdates answers GET /updates with the Recent updates: the last
|
||||
// home.RecentLimit distinct notification events, newest first.
|
||||
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := svc.Updates(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||
}
|
||||
}
|
||||
|
||||
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||
const maxUpdatesSeenBody = 1 << 10
|
||||
|
||||
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||
// created_at of the newest update the household was shown.
|
||||
type updatesSeenRequest struct {
|
||||
Through *string `json:"through"`
|
||||
}
|
||||
|
||||
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||
// household has seen its updates through the body's through, and answers
|
||||
// 204. A through before the one already recorded changes nothing.
|
||||
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Through == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||
return
|
||||
}
|
||||
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||
return
|
||||
}
|
||||
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||
if errors.Is(err, home.ErrSeenInFuture) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
// household (America/New_York) has no plan, no suggestions, no Supply run
|
||||
// and no notifications.
|
||||
func TestGetHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestRouter(t, "dev")
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want is the answer at instant now; the request is read between two
|
||||
// instants, so it matches the answer at one of them.
|
||||
want := func(now time.Time) string {
|
||||
week := household.WeekStart(household.Today(now, loc))
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||
"timezone": loc.String(),
|
||||
"tonight": nil,
|
||||
"sideQuestCount": 0,
|
||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||
"recentUpdates": map[string]any{"unread": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
before := time.Now()
|
||||
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(canonical) != want(before) && string(canonical) != want(after) {
|
||||
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// homeRouter returns a router whose Home reads st, and st.
|
||||
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
svc := newTestHomeOn(t, st)
|
||||
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||
}
|
||||
|
||||
// storeEvent stores event key in st's outbox at instant at for each of
|
||||
// channels, as notify stores an event, and returns at as stored.
|
||||
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// unread reads recentUpdates.unread from GET /home.
|
||||
func unread(t *testing.T, h http.Handler) bool {
|
||||
t.Helper()
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
updates, ok := body["recentUpdates"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||
}
|
||||
return updates["unread"] == true
|
||||
}
|
||||
|
||||
// markSeen posts body to /updates/seen and returns the answer.
|
||||
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
// GET /updates lists the last distinct events newest first, one per event
|
||||
// whatever its channels, in their JSON shape.
|
||||
func TestGetUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
want := `{"updates":[` +
|
||||
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unread is true while an event newer than the last seen one exists,
|
||||
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||
// newer event is stored; marking an older one seen again moves nothing.
|
||||
func TestUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
if unread(t, h) {
|
||||
t.Error("unread with no events")
|
||||
}
|
||||
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event never seen")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newest event was marked seen")
|
||||
}
|
||||
|
||||
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event newer than the last seen")
|
||||
}
|
||||
// Already seen: marking the older event seen again is accepted and
|
||||
// changes nothing.
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("marking an older event seen hid a newer one")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newer event was marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed POST /updates/seen is 400 and records nothing.
|
||||
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||
|
||||
for _, body := range []string{
|
||||
``,
|
||||
`[]`,
|
||||
`{}`,
|
||||
`{"through":null}`,
|
||||
`{"through":42}`,
|
||||
`{"through":"yesterday"}`,
|
||||
`{"through":"2026-10-13 12:00:00"}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||
} {
|
||||
rec := markSeen(t, h, body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _ := homeRouter(t)
|
||||
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -66,12 +67,13 @@ type RouteMounter interface {
|
||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; runner serves
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -105,6 +107,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
}
|
||||
if homeSvc == nil {
|
||||
return nil, errors.New("server: nil home service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -137,6 +142,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -50,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||
}
|
||||
|
||||
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -257,6 +265,71 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestHome returns a home service whose services all share one
|
||||
// migrated temp database with zone America/New_York, with an unstarted job
|
||||
// runner, no model, and Google and Google Chat endpoints that refuse every
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
return newTestHomeOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||
// it seeds with zone America/New_York and the seed notification preferences.
|
||||
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
||||
RevokeURL: "http://127.0.0.1:1/revoke",
|
||||
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||
// asked to title stays untitled.
|
||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
@@ -317,7 +390,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -326,7 +399,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -335,7 +408,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -344,7 +417,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -353,7 +426,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -362,7 +435,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -371,7 +444,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -380,7 +453,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -389,16 +462,25 @@ func TestNewRouterRequiresIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil home service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -407,7 +489,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// PendingCount is how many Side quests are pending on the deck. Unlike
|
||||
// Deck it only reads: it does not fail orphaned batches, so a reader such
|
||||
// as Home never writes.
|
||||
func (s *Service) PendingCount(ctx context.Context) (int, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
|
||||
@@ -66,3 +66,28 @@ SELECT * FROM notification_prefs;
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: HasNotificationSince :one
|
||||
-- Reports whether a notification was stored after since, or at all when
|
||||
-- since is NULL.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
||||
) AS found;
|
||||
|
||||
-- name: ListRecentNotifications :many
|
||||
-- Returns the last lim distinct events stored, newest first: one row per
|
||||
-- event key, the last stored for it whatever its channel, so an event
|
||||
-- stored for push and Google Chat is one row. The last, because a channel
|
||||
-- switched on later can store the same event key again, and
|
||||
-- HasNotificationSince counts that row: an event is as new as its newest
|
||||
-- row, so marking it seen also covers that row.
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT sqlc.arg(lim);
|
||||
|
||||
@@ -154,3 +154,11 @@ RETURNING *;
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?;
|
||||
|
||||
-- name: MarkUpdatesSeen :execrows
|
||||
-- Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
-- already at or after seen_at, so it never moves back. It is not a settings
|
||||
-- edit: updated_by and updated_at are left as they are.
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT));
|
||||
|
||||
+28
-8
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/': true,
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
@@ -85,6 +86,16 @@ describe('App routes', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
@@ -262,12 +273,19 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/me': () => mes.shift()!,
|
||||
'/api/v1/home': () => jsonResponse(200, HOME),
|
||||
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,9 +300,9 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
@@ -295,14 +313,16 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
// manager, shared by every test, offline until an online event. The
|
||||
// manager listens for it only while a query client is mounted, so the
|
||||
// event goes out before cleanup unmounts the app.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/', 'Home'],
|
||||
['/', "Tonight's quest"],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { HomePage } from './home/HomePage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
@@ -45,6 +46,7 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/': <HomePage />,
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||
// Supply run and nothing unread.
|
||||
const NOTHING_YET = {
|
||||
...HOME,
|
||||
tonight: null,
|
||||
sideQuestCount: 0,
|
||||
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
|
||||
recentUpdates: { unread: false },
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Where shows the route a link went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
function renderHome(body: unknown) {
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
|
||||
return renderRouter()
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders mock 1g from GET /api/v1/home', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
const tonight = screen.getByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
|
||||
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
|
||||
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
|
||||
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
|
||||
// setTimeout moves with real time, and the test jumps to midnight.
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
// 23:58 on Friday Oct 2 in New York, the household's zone.
|
||||
vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
|
||||
const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
|
||||
let reads = 0
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(reads).toBe(1)
|
||||
|
||||
// A minute before midnight Home is not read again.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(reads).toBe(1)
|
||||
// At midnight it is, and shows the new day.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
|
||||
const dot = within(bell).getByTestId('unread-dot')
|
||||
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(dot).width).toBe('8px')
|
||||
expectMinHitArea(bell)
|
||||
})
|
||||
|
||||
it('shows the bell without a dot when every update is seen', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates' })
|
||||
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a Side quest tonight "Side quest"', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
|
||||
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the origin when tonight has no minutes', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
|
||||
// No photo: the striped placeholder, named for the dinner.
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
// The tiles stay.
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
|
||||
})
|
||||
|
||||
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 0 })
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Supply run row when no list is ready', async () => {
|
||||
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
|
||||
|
||||
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts a single Side quest in the singular', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 1 })
|
||||
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a week spanning two months in full', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Recipe', '/dinner/d1'],
|
||||
['Quick dinner', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['10 new side quests to swipe', '/discover'],
|
||||
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
|
||||
])('%s goes to %s', async (name, to) => {
|
||||
renderHome(HOME)
|
||||
|
||||
fireEvent.click(await screen.findByRole('link', { name }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
})
|
||||
|
||||
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(declaredStyle(tonight).borderRadius).toBe('32px')
|
||||
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
|
||||
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
|
||||
|
||||
const quick = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(declaredStyle(quick).height).toBe('104px')
|
||||
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
|
||||
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
|
||||
expect(declaredStyle(pantry).height).toBe('104px')
|
||||
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
|
||||
|
||||
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
|
||||
|
||||
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
|
||||
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
|
||||
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
|
||||
expect(declaredStyle(row).minHeight).toBe('44px')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows skeletons while the home loads', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
|
||||
)
|
||||
renderRouter()
|
||||
|
||||
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
answer(jsonResponse(200, HOME))
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failure with Retry, and no made-up content', async () => {
|
||||
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => homes.shift()!,
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a home it cannot read rather than guessing', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
|
||||
})
|
||||
|
||||
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
`tonight's minutes_total ${minutes} is not a positive whole number`,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
|
||||
const { minutes_total: _, ...tonight } = HOME.tonight
|
||||
renderHome({ ...HOME, tonight })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { supplyRunScreen } from '../shopping/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||
import styles from './HomePage.module.css'
|
||||
import { RotationCheck } from './RotationCheck.tsx'
|
||||
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
||||
|
||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||
|
||||
// meta is the Tonight card's "Regular · 40 min" line, the time only when
|
||||
// the recipe says it.
|
||||
function meta({ origin, minutes_total }: Tonight): string {
|
||||
return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}`
|
||||
}
|
||||
|
||||
// TonightCard is the dinner planned for today: its washed photo, name and
|
||||
// meta line, and the dark Recipe pill to its recipe.
|
||||
function TonightCard({ tonight }: { tonight: Tonight }) {
|
||||
return (
|
||||
<section aria-label="Tonight" className={styles.card}>
|
||||
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.cardFoot}>
|
||||
<div className={styles.cardText}>
|
||||
<h2 className={styles.name}>{tonight.name}</h2>
|
||||
<span className={styles.meta}>{meta(tonight)}</span>
|
||||
</div>
|
||||
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
|
||||
Recipe
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// NoPlan stands in for the Tonight card when today has no plan.
|
||||
function NoPlan() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
|
||||
Draft this week's dinners and tonight's quest shows up here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRow is one neutral row under the tiles: a grape-700 icon, its line
|
||||
// and a chevron, linking to the screen it is about.
|
||||
function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) {
|
||||
return (
|
||||
<li>
|
||||
<Link to={to} className={styles.status}>
|
||||
<Icon icon={icon} className={styles.statusIcon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
<Icon icon={ChevronRight} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRows are the rows that apply: Side quests waiting in the deck, and
|
||||
// a Supply run ready. With neither, there are no rows.
|
||||
function StatusRows({ home }: { home: Home }) {
|
||||
const count = home.sideQuestCount
|
||||
const list = home.shoppingList
|
||||
if (count === 0 && !list.ready) return null
|
||||
return (
|
||||
<ul aria-label="Status" className={styles.statuses}>
|
||||
{count > 0 && (
|
||||
<StatusRow to="/discover" icon={Compass}>
|
||||
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
|
||||
</StatusRow>
|
||||
)}
|
||||
{list.ready && (
|
||||
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
|
||||
{`Shopping list ready · ${list.dates}`}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
|
||||
function Tiles() {
|
||||
return (
|
||||
<div className={styles.tiles}>
|
||||
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
|
||||
<Icon icon={Zap} size="nav" />
|
||||
<span className={styles.tileLabel}>Quick dinner</span>
|
||||
</Link>
|
||||
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
|
||||
<Icon icon={Search} size="nav" />
|
||||
<span className={styles.tileLabel}>Cook from what I have</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
|
||||
// rotation?", while a tried dinner waits for the household's answer.
|
||||
//
|
||||
// The bell carries a grape dot while there are unread updates and opens the
|
||||
// Recent updates sheet, which marks them seen.
|
||||
export function HomePage() {
|
||||
const home = useHome()
|
||||
const data = home.data
|
||||
const [updatesOpen, setUpdatesOpen] = useState(false)
|
||||
return (
|
||||
<div className={styles.page} aria-busy={home.isPending}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{data !== undefined ? (
|
||||
<p className={styles.date}>{data.dateLabel}</p>
|
||||
) : (
|
||||
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
|
||||
)}
|
||||
<h1 className={styles.title}>Tonight's quest</h1>
|
||||
</div>
|
||||
<IconButton
|
||||
label={data?.unread ? 'Recent updates, unread' : 'Recent updates'}
|
||||
className={styles.bell}
|
||||
onClick={() => setUpdatesOpen(true)}
|
||||
>
|
||||
<Icon icon={Bell} className={styles.bellIcon} />
|
||||
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
||||
</IconButton>
|
||||
</header>
|
||||
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
|
||||
{home.isError && (
|
||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||
)}
|
||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||
<RotationCheck />
|
||||
<Tiles />
|
||||
{home.isPending && (
|
||||
<div className={styles.statuses} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && <StatusRows home={data} />}
|
||||
<UpdatesSheet open={updatesOpen} onClose={() => setUpdatesOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px 18px 12px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.question {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* "We didn't make it": a text link at the 44 px tap minimum. */
|
||||
.notMade {
|
||||
align-self: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notMade:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notMade:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.confirmation {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
|
||||
// /api/v1/rotation-reviews sends them.
|
||||
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
|
||||
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||
})
|
||||
|
||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
||||
|
||||
// stubApi answers Home's reads like the server: the pending Rotation checks
|
||||
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
|
||||
// decisions until the test answers it; a 200 answer takes the check off the
|
||||
// server's list, as the server does.
|
||||
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
|
||||
const server = [...reviews]
|
||||
const decisions: Deferred[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
|
||||
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
|
||||
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
|
||||
if (init?.method === 'POST' && review !== undefined) {
|
||||
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
const body: unknown = JSON.parse(String(init.body))
|
||||
return new Promise<Response>((resolve) =>
|
||||
decisions.push({
|
||||
path,
|
||||
body,
|
||||
resolve: (response) => {
|
||||
if (response.ok) server.splice(server.indexOf(review), 1)
|
||||
resolve(response)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, decisions, server }
|
||||
}
|
||||
|
||||
// decided is the server's answer to a decision on review.
|
||||
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
|
||||
return jsonResponse(200, {
|
||||
dinner: review.dinner,
|
||||
plan_date: review.plan_date,
|
||||
decision,
|
||||
dinner_status: dinnerStatus,
|
||||
decided_at: '2026-10-02T13:00:00Z',
|
||||
updated_by: 'sub-1',
|
||||
})
|
||||
}
|
||||
|
||||
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
|
||||
// at path.
|
||||
async function renderHome(path = '/') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
|
||||
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
|
||||
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
|
||||
describe('RotationCheck', () => {
|
||||
it('shows no card when nothing waits for an answer', async () => {
|
||||
const { fetchMock } = stubApi([])
|
||||
await renderHome()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
|
||||
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
|
||||
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
|
||||
|
||||
const keep = within(check).getByRole('button', { name: 'Keep it' })
|
||||
const drop = within(check).getByRole('button', { name: 'Not for us' })
|
||||
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
|
||||
expect(keep.className).toMatch(/primary/)
|
||||
expect(drop.className).toMatch(/outline/)
|
||||
expect(notMade.className).toMatch(/notMade/)
|
||||
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
|
||||
|
||||
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// The card waits for the household: nothing is sent unasked.
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(posts(fetchMock)).toHaveLength(0)
|
||||
expect(await card()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
|
||||
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
|
||||
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
|
||||
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
|
||||
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: label }))
|
||||
|
||||
// Applied before the server answers.
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
|
||||
expect(posts(fetchMock)).toHaveLength(1)
|
||||
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
|
||||
// Rotation checks are read again once saved; none is left.
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
const next = await card()
|
||||
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
|
||||
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
|
||||
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
|
||||
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
|
||||
|
||||
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
|
||||
await waitFor(() => expect(decisions).toHaveLength(2))
|
||||
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
|
||||
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the check back with an ErrorBanner when the answer fails', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
|
||||
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The server closed the check, so the list read again has none.
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('reports Rotation checks it cannot load, with Retry', async () => {
|
||||
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) =>
|
||||
Promise.resolve(
|
||||
String(input) === ME_PATH
|
||||
? jsonResponse(200, ME)
|
||||
: String(input) === HOME_PATH
|
||||
? jsonResponse(200, HOME)
|
||||
: answers.shift()!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await renderHome()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives; a notification tap there reads the checks again.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) =>
|
||||
Promise.resolve(
|
||||
String(input) === ME_PATH
|
||||
? jsonResponse(200, ME)
|
||||
: String(input) === HOME_PATH
|
||||
? jsonResponse(200, HOME)
|
||||
: answers.shift()!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await renderHome()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
|
||||
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens Home with the card from a rotation_review push notification', async () => {
|
||||
stubApi([SHAKSHUKA])
|
||||
const origin = 'https://mealquest.test'
|
||||
const { notice } = parsePushPayload(
|
||||
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
|
||||
origin,
|
||||
)
|
||||
const path = clickPath({ url: notice.url }, origin)
|
||||
expect(path).toBe('/')
|
||||
|
||||
await renderHome(path)
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const { fetchMock, server } = stubApi([])
|
||||
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
|
||||
await renderHome('/')
|
||||
const home = await screen.findByRole('heading', { name: 'Pizza night' })
|
||||
await waitFor(() => expect(reads()).toBe(1))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
|
||||
// Within the cached list's staleTime the Rotation check comes due and
|
||||
// its notification is tapped while Home is open at /.
|
||||
server.push(SHAKSHUKA)
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(reads()).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
|
||||
import styles from './RotationCheck.module.css'
|
||||
|
||||
// CONFIRMATION is what Home says once the household has answered.
|
||||
const CONFIRMATION: Record<Decision, string> = {
|
||||
keep: 'Added to your Regulars',
|
||||
drop: "Got it — we won't suggest it again",
|
||||
not_made: 'Back in your Quest log',
|
||||
}
|
||||
|
||||
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
|
||||
|
||||
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
|
||||
// photo, "Last night: {dinner}", and the three answers.
|
||||
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
||||
const { dinner } = review
|
||||
return (
|
||||
<section aria-label="Keep it in rotation?" className={styles.card}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.body}>
|
||||
<p className={styles.kicker}>Last night: {dinner.name}</p>
|
||||
<h2 className={styles.question}>Keep it in rotation?</h2>
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={disabled} onClick={() => onDecide('keep')}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
|
||||
Not for us
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
|
||||
We didn't make it
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// RotationCheck is the Rotation check on Home: the card for the oldest
|
||||
// pending check (GET /api/v1/rotation-reviews), which stays until the
|
||||
// household answers it — nothing is decided for them. An answer is applied
|
||||
// at once: Home confirms it and shows the next pending check, if any, while
|
||||
// the answer is saved; the answers are locked until it settles. A failed
|
||||
// answer puts its check back with an ErrorBanner. A failed read shows an
|
||||
// ErrorBanner, beside the checks last read: a card already shown stays
|
||||
// until it is answered.
|
||||
export function RotationCheck() {
|
||||
const reviews = useRotationReviews()
|
||||
const decide = useDecideRotationReview()
|
||||
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
|
||||
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
||||
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
||||
return (
|
||||
<>
|
||||
{answered !== undefined && (
|
||||
<p role="status" className={styles.confirmation}>
|
||||
{CONFIRMATION[answered.decision]}
|
||||
</p>
|
||||
)}
|
||||
{reviews.isError && (
|
||||
<ErrorBanner onRetry={() => void reviews.refetch()}>
|
||||
Couldn't load the Rotation check. {reviews.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
|
||||
{next !== undefined && (
|
||||
<RotationCard
|
||||
key={next.dinner.id}
|
||||
review={next}
|
||||
disabled={decide.isPending}
|
||||
onDecide={(decision) => decide.mutate({ review: next, decision })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// minutesAgo is the stored form of the instant n minutes before now.
|
||||
function minutesAgo(n: number): string {
|
||||
return new Date(Date.now() - n * 60_000).toISOString()
|
||||
}
|
||||
|
||||
// UPDATES are a GET /api/v1/updates body, newest first: the week drafted
|
||||
// five minutes ago, new Side quests three hours ago and the Supply run two
|
||||
// days ago.
|
||||
const UPDATES = {
|
||||
updates: [
|
||||
{
|
||||
type: 'plan_ready',
|
||||
title: 'Your week is drafted',
|
||||
body: 'Take a look at the Draft board.',
|
||||
url: '/plan?week=2026-10-12',
|
||||
created_at: minutesAgo(5),
|
||||
},
|
||||
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||
{
|
||||
type: 'list_ready',
|
||||
title: 'Supply run ready',
|
||||
body: 'Week 41 · Oct 5 – 11',
|
||||
url: '/plan/shopping?week=2026-10-05',
|
||||
created_at: minutesAgo(2 * 24 * 60),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. Home is unread until the updates are marked
|
||||
// seen, as the server answers.
|
||||
function stubServer(responders: Record<string, Responder> = {}) {
|
||||
let seen = false
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
||||
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
||||
seen = true
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// posts lists the bodies of the POST /updates/seen requests sent, each of
|
||||
// which must carry the CSRF token.
|
||||
function posts(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH)
|
||||
.map(([, init]) => {
|
||||
expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
return String(init?.body)
|
||||
})
|
||||
}
|
||||
|
||||
function homeReads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET')
|
||||
.length
|
||||
}
|
||||
|
||||
function updatesReads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(
|
||||
([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET',
|
||||
).length
|
||||
}
|
||||
|
||||
// Where shows the route a row went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
// renderHome loads /me (for the CSRF token the POST needs) and renders Home,
|
||||
// answering its query client.
|
||||
async function renderHome() {
|
||||
const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
const queryClient = createQueryClient()
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
return queryClient
|
||||
}
|
||||
|
||||
// openSheet taps the unread bell and answers the open sheet.
|
||||
async function openSheet() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' }))
|
||||
return screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
}
|
||||
|
||||
async function rows(sheet: HTMLElement) {
|
||||
return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem')
|
||||
}
|
||||
|
||||
describe('Recent updates sheet', () => {
|
||||
it('lists the updates newest first with how long ago each came', async () => {
|
||||
stubServer()
|
||||
await renderHome()
|
||||
const listed = await rows(await openSheet())
|
||||
|
||||
expect(listed).toHaveLength(3)
|
||||
expect(listed[0]).toHaveTextContent('Your week is drafted')
|
||||
expect(listed[0]).toHaveTextContent('Take a look at the Draft board.')
|
||||
expect(listed[0]).toHaveTextContent('5 min ago')
|
||||
expect(listed[1]).toHaveTextContent('5 new Side quests')
|
||||
expect(listed[1]).toHaveTextContent('3 hr ago')
|
||||
expect(listed[2]).toHaveTextContent('Supply run ready')
|
||||
expect(listed[2]).toHaveTextContent('2 days ago')
|
||||
expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at)
|
||||
})
|
||||
|
||||
it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => {
|
||||
const fetchMock = stubServer()
|
||||
await renderHome()
|
||||
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
|
||||
await openSheet()
|
||||
|
||||
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })]))
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
// Home is read again once the mark is saved, and the server says
|
||||
// nothing is unread: the dot does not come back.
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => {
|
||||
// The server holds the stored events, newest first, and says Home is
|
||||
// unread while the newest is after the time marked seen.
|
||||
const stored = [...UPDATES.updates]
|
||||
let seenThrough = ''
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }),
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }),
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: (init) => {
|
||||
seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
const queryClient = await renderHome()
|
||||
await rows(await openSheet())
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
|
||||
// A new event is stored a moment later, well within the query client's
|
||||
// staleTime, and Home is read again, as on coming back to it.
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
await queryClient.invalidateQueries({ queryKey: HOME_KEY })
|
||||
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
|
||||
|
||||
const listed = await rows(await openSheet())
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() =>
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
|
||||
JSON.stringify({ through: reminder.created_at }),
|
||||
]),
|
||||
)
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(4))
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => {
|
||||
const stored = [...UPDATES.updates]
|
||||
let reads = 0
|
||||
let answer: (() => void) | undefined
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: () => {
|
||||
const body = { updates: [...stored] }
|
||||
reads++
|
||||
// Every reading after the first is held until the test answers it.
|
||||
if (reads === 1) return jsonResponse(200, body)
|
||||
return new Promise<Response>((resolve) => (answer = () => resolve(jsonResponse(200, body))))
|
||||
},
|
||||
})
|
||||
await renderHome()
|
||||
await rows(await openSheet())
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
|
||||
// Closed and opened again at once, before any cache-collection timer
|
||||
// could run, with a new event stored in between.
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
|
||||
// The list held from the first opening is neither shown nor marked
|
||||
// seen while this opening's read is in flight.
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
|
||||
expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toHaveLength(1)
|
||||
|
||||
answer!()
|
||||
const listed = await rows(sheet)
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() =>
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
|
||||
JSON.stringify({ through: reminder.created_at }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => {
|
||||
const stored = [...UPDATES.updates]
|
||||
let firstAborted = false
|
||||
let answerFirst: (() => void) | undefined
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: (init) => {
|
||||
const body = { updates: [...stored] }
|
||||
reads++
|
||||
if (reads > 1) return jsonResponse(200, body)
|
||||
// The first reading is held open, and fails as fetch does if it is
|
||||
// aborted.
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
answerFirst = () => resolve(jsonResponse(200, body))
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
firstAborted = true
|
||||
reject(init.signal!.reason as Error)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
await renderHome()
|
||||
await openSheet()
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(1))
|
||||
|
||||
// Closed before the first read answers, a new event stored, and opened
|
||||
// again at once.
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
|
||||
// The first read was cancelled, and the reopen sent its own.
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
|
||||
expect(firstAborted).toBe(true)
|
||||
// Answering the cancelled read changes nothing.
|
||||
answerFirst!()
|
||||
|
||||
const listed = await rows(sheet)
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })]))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, '/plan?week=2026-10-12'],
|
||||
[1, '/discover'],
|
||||
[2, '/plan/shopping?week=2026-10-05'],
|
||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||
stubServer()
|
||||
await renderHome()
|
||||
const listed = await rows(await openSheet())
|
||||
|
||||
fireEvent.click(within(listed[i]!).getByRole('button'))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says there are no updates yet, and marks nothing seen', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) })
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
})
|
||||
|
||||
it('puts the dot back and says so when marking the updates seen fails', async () => {
|
||||
let refuse!: (response: Response) => void
|
||||
const fetchMock = stubServer({
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: () => new Promise<Response>((resolve) => (refuse = resolve)),
|
||||
})
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
// Cleared at once, while the mark is in flight.
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
|
||||
refuse(errorResponse(500, 'internal'))
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't mark your updates as seen. internal message",
|
||||
)
|
||||
expect(screen.getByTestId('unread-dot')).toBeInTheDocument()
|
||||
// The updates are still listed.
|
||||
expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports updates it cannot load, with Retry', async () => {
|
||||
let fail = true
|
||||
stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) })
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message")
|
||||
fail = false
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' }))
|
||||
expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an update that would open another site rather than listing it', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }),
|
||||
})
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||
'update url "//evil.example/plan" is not a path on this app',
|
||||
)
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ago } from '../plan/PlanPage.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts'
|
||||
import styles from './UpdatesSheet.module.css'
|
||||
|
||||
const ICONS: Record<UpdateType, LucideIcon> = {
|
||||
plan_ready: CalendarDays,
|
||||
list_ready: ShoppingCart,
|
||||
day_of_reminder: ChefHat,
|
||||
new_suggestions: Compass,
|
||||
rotation_review: Repeat,
|
||||
}
|
||||
|
||||
// UpdateRow is one update: its icon, title and body, and how long ago it
|
||||
// came. A tap opens its url.
|
||||
function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) {
|
||||
return (
|
||||
<li>
|
||||
<ListRow
|
||||
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
|
||||
title={update.title}
|
||||
meta={update.body === '' ? undefined : update.body}
|
||||
trailing={
|
||||
<time dateTime={update.created_at} className={styles.time}>
|
||||
{ago(new Date(update.created_at), now)}
|
||||
</time>
|
||||
}
|
||||
onClick={() => onOpen(update.url)}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Updates lists the Recent updates, newest first, and marks them seen
|
||||
// through the newest once they are shown, so the bell's dot clears.
|
||||
function Updates({ onOpen }: { onOpen: (url: string) => void }) {
|
||||
const updates = useUpdates()
|
||||
const markSeen = useMarkUpdatesSeen()
|
||||
const { mutate } = markSeen
|
||||
// fresh says this opening's read has come back; until then a list still
|
||||
// held from an earlier opening is neither shown nor marked seen.
|
||||
const fresh = updates.isSuccess && updates.isFetchedAfterMount
|
||||
const newest = fresh ? updates.data[0]?.created_at : undefined
|
||||
// The instant the relative times count from, fixed while the sheet is
|
||||
// open.
|
||||
const [now] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
if (newest !== undefined) mutate(newest)
|
||||
}, [newest, mutate])
|
||||
|
||||
if (updates.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (!fresh) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (updates.data.length === 0) {
|
||||
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{markSeen.isError && newest !== undefined && (
|
||||
<ErrorBanner onRetry={() => mutate(newest)}>
|
||||
Couldn't mark your updates as seen. {markSeen.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
<ul aria-label="Recent updates" className={styles.rows}>
|
||||
{updates.data.map((update, i) => (
|
||||
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type UpdatesSheetProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// UpdatesSheet is the Recent updates sheet the Home bell opens: the last
|
||||
// ten notification events with how long ago each came, each opening the
|
||||
// screen it is about. Opening it marks them seen.
|
||||
export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) {
|
||||
const titleId = useId()
|
||||
const navigate = useNavigate()
|
||||
const openUpdate = (url: string) => {
|
||||
onClose()
|
||||
void navigate(url)
|
||||
}
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Recent updates
|
||||
</h2>
|
||||
<Updates onOpen={openUpdate} />
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { parseHome, parseUpdates } from './api.ts'
|
||||
|
||||
const origin = 'https://mealquest.example'
|
||||
const where = 'GET /api/v1/updates'
|
||||
|
||||
// body is a GET /api/v1/updates body holding one update that opens url.
|
||||
function body(url: string) {
|
||||
return {
|
||||
updates: [{ type: 'plan_ready', title: 'Your week is drafted', body: '', url, created_at: '2026-10-10T16:00:00.000Z' }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseUpdates', () => {
|
||||
it('keeps a same-origin path with its query and fragment, as parsed', () => {
|
||||
expect(parseUpdates(where, body('/plan/shopping?week=2026-10-05#tuesday'), origin)[0]!.url).toBe(
|
||||
'/plan/shopping?week=2026-10-05#tuesday',
|
||||
)
|
||||
expect(parseUpdates(where, body('/plan/../discover'), origin)[0]!.url).toBe('/discover')
|
||||
})
|
||||
|
||||
// Each of these would open another site, or run script, once parsed: URL
|
||||
// parsing strips tabs and newlines and reads a backslash as a slash.
|
||||
it.each([
|
||||
['a tab', '/\t/evil.example/x'],
|
||||
['a newline', '/\n/evil.example/x'],
|
||||
['a carriage return', '/\r/evil.example/x'],
|
||||
['a backslash', '/\\evil.example/x'],
|
||||
['dot segments', '/..//evil.example/x'],
|
||||
['a protocol-relative URL', '//evil.example/x'],
|
||||
['an absolute URL', 'https://evil.example/x'],
|
||||
['javascript:', 'javascript:alert(1)'],
|
||||
['a relative path', 'plan'],
|
||||
])('refuses an update url with %s', (_, url) => {
|
||||
expect(() => parseUpdates(where, body(url), origin)).toThrow(
|
||||
`${where}: update url ${JSON.stringify(url)} is not a path on this app`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHome', () => {
|
||||
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
|
||||
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
|
||||
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
|
||||
)
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
|
||||
'GET /api/v1/home: timezone undefined is not an IANA zone',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
|
||||
import { appPath } from '../push/notification.ts'
|
||||
|
||||
export const HOME_PATH = '/api/v1/home'
|
||||
export const HOME_KEY = ['home'] as const
|
||||
export const UPDATES_PATH = '/api/v1/updates'
|
||||
export const UPDATES_SEEN_PATH = '/api/v1/updates/seen'
|
||||
export const UPDATES_KEY = ['updates'] as const
|
||||
|
||||
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||
export type Origin = (typeof ORIGINS)[number]
|
||||
|
||||
// Tonight is the dinner planned for today: photo_id is its stored photo's
|
||||
// id, from photo, and minutes_total its minutes from prep to table, a
|
||||
// positive whole number, or null when the recipe does not say.
|
||||
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
|
||||
|
||||
// ShoppingListStatus is the Supply run Home points at: next week's once it
|
||||
// has one, otherwise this week's, ready when that week has a list.
|
||||
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
|
||||
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
|
||||
// Oct 5 – 11").
|
||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||
|
||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
|
||||
// next day; tonight is null when today has no plan; sideQuestCount is how
|
||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
timezone: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
function parseTonight(where: string, body: unknown): Tonight | null {
|
||||
if (body === null) return null
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: tonight is not a dinner`)
|
||||
}
|
||||
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
|
||||
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
photo_id: photoId(where, body.photo),
|
||||
origin: oneOf(where, 'origin', ORIGINS, body.origin),
|
||||
minutes_total: minutes as number | null,
|
||||
}
|
||||
}
|
||||
|
||||
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
|
||||
// list, ready or not, must carry the API's label for its week, which must be
|
||||
// the label weekStart has, since the status row shows dates from weekStart.
|
||||
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
|
||||
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
|
||||
}
|
||||
const week = weekLabel(list.weekStart)
|
||||
if (typeof list.label !== 'string' || list.label === '') {
|
||||
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
|
||||
}
|
||||
if (list.label !== `${week.week} · ${week.dates}`) {
|
||||
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
|
||||
}
|
||||
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
|
||||
}
|
||||
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
|
||||
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
|
||||
}
|
||||
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||
}
|
||||
const updates = body.recentUpdates
|
||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
timezone: body.timezone,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||
unread: updates.unread,
|
||||
}
|
||||
}
|
||||
|
||||
// isZone says whether zone is an IANA zone this browser knows.
|
||||
function isZone(zone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: zone })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
|
||||
// again at the household's next local midnight, counted from each read, so
|
||||
// a Home left open does not keep yesterday's date and dinner.
|
||||
export function useHome() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const home = useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||
})
|
||||
const zone = home.data?.timezone
|
||||
const readAt = home.dataUpdatedAt
|
||||
useEffect(() => {
|
||||
if (zone === undefined) return
|
||||
const timer = setTimeout(
|
||||
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
|
||||
untilLocalMidnight(zone, new Date()),
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [queryClient, zone, readAt])
|
||||
return home
|
||||
}
|
||||
|
||||
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||
export type UpdateType = (typeof UPDATE_TYPES)[number]
|
||||
|
||||
// Update is one of the Recent updates: a notification event, whatever
|
||||
// channels it went to. url is the same-origin path it opens; created_at is
|
||||
// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen
|
||||
// takes back.
|
||||
export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string }
|
||||
|
||||
// parseUpdate checks one update. Its url must stay on origin once parsed, as
|
||||
// the server only ever stores a path on the app's own origin; url is that
|
||||
// path as parsed.
|
||||
function parseUpdate(where: string, body: unknown, origin: string): Update {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.title !== 'string' ||
|
||||
typeof body.body !== 'string' ||
|
||||
typeof body.url !== 'string' ||
|
||||
typeof body.created_at !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: an update is not a notification event`)
|
||||
}
|
||||
const url = appPath(body.url, origin)
|
||||
if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`)
|
||||
if (Number.isNaN(Date.parse(body.created_at))) {
|
||||
throw new Error(`${where}: update created_at "${body.created_at}" is not a time`)
|
||||
}
|
||||
return {
|
||||
type: oneOf(where, 'type', UPDATE_TYPES, body.type),
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
url,
|
||||
created_at: body.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// parseUpdates checks a GET /api/v1/updates body, from the app at origin:
|
||||
// the updates, newest first.
|
||||
export function parseUpdates(where: string, body: unknown, origin: string): Update[] {
|
||||
if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`)
|
||||
return body.updates.map((update) => parseUpdate(where, update, origin))
|
||||
}
|
||||
|
||||
// useUpdates is the Recent updates (GET /api/v1/updates): the last ten
|
||||
// notification events, newest first. Each opening of the sheet reads it
|
||||
// afresh (refetchOnMount 'always'), whatever the query client still holds;
|
||||
// the sheet shows, and marks seen, only the list that opening read. Closing
|
||||
// the sheet aborts a read still in flight, so a reopen sends its own rather
|
||||
// than adopt one sent before it.
|
||||
export function useUpdates() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: UPDATES_KEY,
|
||||
queryFn: async ({ signal }) =>
|
||||
parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch<unknown>(UPDATES_PATH, { signal }), window.location.origin),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
const MARK_SEEN_KEY = ['updates', 'seen'] as const
|
||||
|
||||
// useMarkUpdatesSeen records that the household has seen its updates
|
||||
// through the created_at of the newest one shown (POST
|
||||
// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home
|
||||
// read in flight is stopped so it cannot bring it back; if the server
|
||||
// refuses, the dot is put back as it was before the first of the marks in
|
||||
// flight. Once the last mark in flight settles Home is read again, so the
|
||||
// dot shows only for an event stored since.
|
||||
export function useMarkUpdatesSeen() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
// alone says whether this is the only mark in flight; each counts itself
|
||||
// until it has settled.
|
||||
const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1
|
||||
return useMutation({
|
||||
mutationKey: MARK_SEEN_KEY,
|
||||
mutationFn: async (through: string) => {
|
||||
await apiFetch<unknown>(UPDATES_SEEN_PATH, { method: 'POST', body: { through } })
|
||||
},
|
||||
onMutate: async () => {
|
||||
// A mark sent while another is in flight would snapshot that one's
|
||||
// optimistic answer, so only the first snapshots.
|
||||
const unread = alone() ? queryClient.getQueryData<Home>(HOME_KEY)?.unread : undefined
|
||||
await queryClient.cancelQueries({ queryKey: HOME_KEY })
|
||||
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread: false })
|
||||
return { unread }
|
||||
},
|
||||
onError: (_error, _through, context) => {
|
||||
if (context?.unread === undefined || !alone()) return
|
||||
const { unread } = context
|
||||
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread })
|
||||
},
|
||||
onSettled: () => {
|
||||
if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { photoId } from '../dinners/api.ts'
|
||||
import { isNotificationOpened } from '../push/notification.ts'
|
||||
|
||||
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
|
||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
||||
const DINNERS_KEY = ['dinners'] as const
|
||||
|
||||
// rotationReviewPath is where the household answers "Keep it in rotation?"
|
||||
// for a tried dinner.
|
||||
export function rotationReviewPath(dinnerId: string): string {
|
||||
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
|
||||
}
|
||||
|
||||
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
|
||||
// for us") archives it so it is never suggested again, and not_made ("We
|
||||
// didn't make it") returns it to the Quest log. Keep is the only way a tried
|
||||
// dinner becomes a Regular; nothing decides one automatically.
|
||||
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
|
||||
export type Decision = (typeof DECISIONS)[number]
|
||||
|
||||
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
|
||||
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
|
||||
// planned on.
|
||||
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
|
||||
|
||||
function parseReview(where: string, body: unknown): RotationReview {
|
||||
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
|
||||
const dinner = body.dinner
|
||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
||||
throw new Error(`${where}: a Rotation check has no dinner`)
|
||||
}
|
||||
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
|
||||
}
|
||||
|
||||
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
|
||||
// pending Rotation checks, oldest plan date first.
|
||||
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
|
||||
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
|
||||
return body.reviews.map((review) => parseReview(where, review))
|
||||
}
|
||||
|
||||
// useRotationReviews is the pending Rotation checks (GET
|
||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
||||
// reads them afresh, and so does a notification tap that focuses Home
|
||||
// already open (the service worker's NotificationOpened), so Home opened
|
||||
// from a rotation_review notification shows the check it is about.
|
||||
export function useRotationReviews() {
|
||||
const { apiFetch } = useApi()
|
||||
const query = useQuery({
|
||||
queryKey: ROTATION_REVIEWS_KEY,
|
||||
queryFn: async () =>
|
||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const { refetch } = query
|
||||
useEffect(() => {
|
||||
// Without a service worker there are no notifications to tap.
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
const container = navigator.serviceWorker
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (isNotificationOpened(event.data)) void refetch()
|
||||
}
|
||||
container.addEventListener('message', onMessage)
|
||||
return () => container.removeEventListener('message', onMessage)
|
||||
}, [refetch])
|
||||
return query
|
||||
}
|
||||
|
||||
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
||||
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
|
||||
// must be that dinner, decided that way.
|
||||
//
|
||||
// Home leaves the check out while its answer is pending (the mutation's
|
||||
// variables say which), so the next one shows at once; a read in flight is
|
||||
// stopped so it cannot bring the check back meanwhile. Once saved, the check
|
||||
// leaves the cached list; once failed, it is shown again as the list has it.
|
||||
// Either way the Rotation checks and the dinners lists (the Regulars and the
|
||||
// Quest log the answer moved it to) are read again.
|
||||
export function useDecideRotationReview() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
|
||||
mutationFn: async ({ review, decision }) => {
|
||||
const path = rotationReviewPath(review.dinner.id)
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
|
||||
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
|
||||
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
},
|
||||
onSuccess: (_data, { review }) => {
|
||||
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
|
||||
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
@@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||
subscriptions.push(JSON.parse(String(init?.body)))
|
||||
@@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) {
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/" element={<h1>Tonight's quest</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
@@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
function useIphoneSafariTab() {
|
||||
@@ -210,7 +211,7 @@ describe('onboarding step 3', () => {
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -314,7 +315,7 @@ describe('onboarding step 3', () => {
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(server.subscriptions).toEqual([])
|
||||
@@ -358,7 +359,7 @@ describe('onboarding step 3', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||
// screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route
|
||||
path="/plan"
|
||||
element={
|
||||
<>
|
||||
<PlanPage />
|
||||
<Searched />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -190,6 +199,11 @@ function Opened() {
|
||||
)
|
||||
}
|
||||
|
||||
// Searched shows the Plan's search, the week it keeps in ?week=.
|
||||
function Searched() {
|
||||
return <p data-testid="search">{useLocation().search}</p>
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
|
||||
stubServer({
|
||||
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
...lists,
|
||||
})
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
// The week buttons keep ?week= on the week shown.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
|
||||
})
|
||||
|
||||
it('says so when ?week= is not a Monday, and reads no plan', async () => {
|
||||
const fetchMock = stubServer({ ...lists })
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
|
||||
// ?week=, as a plan_ready update links it, else on next week once this
|
||||
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// The default week is chosen once, not on every render: the plan_draft
|
||||
// time passing does not move the page to another week.
|
||||
const [opened, setOpened] = useState<string | null>(null)
|
||||
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
|
||||
const week = chosen ?? opened
|
||||
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
|
||||
|
||||
let content
|
||||
if (settings.isError) {
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
} else if (settings.isError) {
|
||||
content = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import {
|
||||
addDays,
|
||||
dayAndDate,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
untilLocalMidnight,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart,
|
||||
} from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -72,4 +84,45 @@ describe('week helpers', () => {
|
||||
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
|
||||
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
|
||||
})
|
||||
|
||||
it('says whether a date is a real Monday', () => {
|
||||
expect(isMonday('2026-10-05')).toBe(true)
|
||||
expect(isMonday('2026-10-07')).toBe(false)
|
||||
expect(isMonday('2026-02-30')).toBe(false)
|
||||
expect(isMonday('next week')).toBe(false)
|
||||
})
|
||||
|
||||
it('counts the time to the next midnight in the household zone, not UTC', () => {
|
||||
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
|
||||
// At midnight itself the next is a day away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
|
||||
// The same instant is 13:00 in Tokyo: eleven hours to go.
|
||||
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('counts a day shortened by the spring clock change as 23 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the
|
||||
// next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour)
|
||||
// 03:30 EDT, after the change: 20.5 hours to midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour)
|
||||
// At midnight starting the 23-hour day, the next midnight is 23 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour)
|
||||
// Noon EST the day before: the change is after the coming midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour)
|
||||
})
|
||||
|
||||
it('counts a day lengthened by the autumn clock change as 25 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour)
|
||||
})
|
||||
|
||||
it('starts the next day at the clock change when the clocks skip midnight', () => {
|
||||
// Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z);
|
||||
// at 23:00 -04 on September 5 the next day starts an hour later.
|
||||
expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
||||
return addDays(date, -sinceMonday)
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
export function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekStart(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||
export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
@@ -115,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||
}
|
||||
|
||||
// wallClock is the household-local wall clock at instant t in IANA zone,
|
||||
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
|
||||
function wallClock(zone: string, t: number): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date(t))
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
||||
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
|
||||
return wall + (((t % 1000) + 1000) % 1000)
|
||||
}
|
||||
|
||||
// untilLocalMidnight is how many milliseconds after instant now the next
|
||||
// local day begins in IANA zone. It finds the actual instant of the next
|
||||
// day's midnight, so a day shortened or lengthened by a change of UTC offset
|
||||
// is counted as it is. The candidates are that midnight at the zone's offset
|
||||
// now and at the offset that candidate lands on; the earliest that falls on
|
||||
// the next day is its start, which is the offset change itself when the
|
||||
// clocks skip midnight.
|
||||
export function untilLocalMidnight(zone: string, now: Date): number {
|
||||
const t = now.getTime()
|
||||
const tomorrow = addDays(localNow(zone, now).date, 1)
|
||||
const midnight = midnightUTC(tomorrow).getTime()
|
||||
const first = midnight - (wallClock(zone, t) - t)
|
||||
const second = midnight - (wallClock(zone, first) - first)
|
||||
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
|
||||
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
|
||||
return Math.min(...starts) - t
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
isNotificationOpened,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
@@ -178,6 +179,7 @@ describe('openNotification', () => {
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: vi.fn(),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
@@ -193,6 +195,18 @@ describe('openNotification', () => {
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(win.postMessage).not.toHaveBeenCalled()
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/' }, all, origin)
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
|
||||
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
postMessage(message: NotificationOpened): void
|
||||
}
|
||||
|
||||
// NotificationOpened is the message a tap posts to an open window it focuses
|
||||
// without navigating, because the window is already at url: nothing remounts
|
||||
// there, so the screen reads afresh what the notification is about.
|
||||
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
|
||||
|
||||
// isNotificationOpened reports whether a message from the service worker is
|
||||
// NotificationOpened.
|
||||
export function isNotificationOpened(message: unknown): message is NotificationOpened {
|
||||
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
// first, while the tap still allows it, then navigate; a window already at
|
||||
// the url is told NotificationOpened instead.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekOf(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,18 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a
|
||||
// Regular, tonight; ten Side quests; next week's Supply run ready; an
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
dateLabel: 'Friday, Oct 2',
|
||||
timezone: 'America/New_York',
|
||||
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
recentUpdates: { unread: true },
|
||||
}
|
||||
|
||||
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
||||
// seed channels and reminder time.
|
||||
export const NOTIFICATION_SETTINGS = {
|
||||
|
||||
Reference in New Issue
Block a user