Fix Home dashboard integration findings (Feature 21)
- F21-01: plan_ready and list_ready links carry their week (/plan?week=, /plan/shopping?week=), and the Plan opens on ?week= and keeps it on the week shown, so an older update opens its week. - F21-02: a failed refresh of the Rotation check shows its ErrorBanner beside the cached pending card instead of replacing it. - F21-03: Home carries the household's timezone and is read again at its next local midnight. - F21-04: Home counts pending Side quests through a read-only query (suggest.PendingCount), so reading Home never writes batches. Task: ac3dddf0-a05f-4e43-a5ad-3105fe6bdd95 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -793,13 +793,13 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
- 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
|
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||||
change and its notification commit together or not at all:
|
change and its notification commit together or not at all:
|
||||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
|
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job stores the
|
||||||
AI's draft of a week. Only the first draft of a week notifies: a
|
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
|
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",
|
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
|
or "The plan for the week of Oct 26") and its body the drafted
|
||||||
dinners Monday to Sunday.
|
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
|
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||||
after Push to Calendar; remaking it does not notify.
|
after Push to Calendar; remaking it does not notify.
|
||||||
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
||||||
@@ -1098,6 +1098,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dateLabel": "Friday, Oct 2",
|
"dateLabel": "Friday, Oct 2",
|
||||||
|
"timezone": "America/Chicago",
|
||||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||||
"sideQuestCount": 10,
|
"sideQuestCount": 10,
|
||||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||||
@@ -1107,8 +1108,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
|
|
||||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
`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
|
when the week has no plan; it changes at the household's local
|
||||||
midnight. `photo` and `minutes_total` are null when unknown.
|
midnight, so the PWA reads Home again at the next local midnight of
|
||||||
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
|
`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
|
`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
|
the current week's (`shopping.Get`); with neither, it is the current
|
||||||
week with `ready` false. `recentUpdates.unread` is true when a
|
week with `ready` false. `recentUpdates.unread` is true when a
|
||||||
@@ -1123,7 +1127,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
one update):
|
one update):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Every field comes from the stored payload, which never holds a webhook
|
Every field comes from the stored payload, which never holds a webhook
|
||||||
|
|||||||
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
|
|||||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cards' "Open" buttons link under the public URL.
|
// 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", 1: "https://mealquest.example/plan/shopping"} {
|
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"])
|
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||||
var cards []cardV2
|
var cards []cardV2
|
||||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
|||||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||||
Title: planTitle(week, thisWeek),
|
Title: planTitle(week, thisWeek),
|
||||||
Body: nameList(dinners),
|
Body: nameList(dinners),
|
||||||
URL: "/plan",
|
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||||||
Week: week,
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
|||||||
Key: string(notify.ListReady) + ":" + week.String(),
|
Key: string(notify.ListReady) + ":" + week.String(),
|
||||||
Title: "This week's Supply run is ready",
|
Title: "This week's Supply run is ready",
|
||||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
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,
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready",
|
name: "plan ready",
|
||||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan",
|
url: "/plan?week=2026-10-12",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's plan is ready",
|
title: "This week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||||
@@ -48,7 +48,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready with three dinners",
|
name: "plan ready with three dinners",
|
||||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan",
|
url: "/plan?week=2026-10-12",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's plan is ready",
|
title: "This week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala",
|
body: "Tacos, Bolognese, Tikka masala",
|
||||||
@@ -58,7 +58,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready for next week",
|
name: "plan ready for next week",
|
||||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan",
|
url: "/plan?week=2026-10-12",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "Next week's plan is ready",
|
title: "Next week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||||
@@ -68,7 +68,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready for a later week",
|
name: "plan ready for a later week",
|
||||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||||
key: "plan_ready:2026-10-26",
|
key: "plan_ready:2026-10-26",
|
||||||
url: "/plan",
|
url: "/plan?week=2026-10-26",
|
||||||
week: "2026-10-26",
|
week: "2026-10-26",
|
||||||
title: "The plan for the week of Oct 26 is ready",
|
title: "The plan for the week of Oct 26 is ready",
|
||||||
body: "Tacos",
|
body: "Tacos",
|
||||||
@@ -78,7 +78,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "list ready",
|
name: "list ready",
|
||||||
ev: ListReady(week, 19, 7),
|
ev: ListReady(week, 19, 7),
|
||||||
key: "list_ready:2026-10-12",
|
key: "list_ready:2026-10-12",
|
||||||
url: "/plan/shopping",
|
url: "/plan/shopping?week=2026-10-12",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's Supply run is ready",
|
title: "This week's Supply run is ready",
|
||||||
body: "19 items to buy for 7 dinners",
|
body: "19 items to buy for 7 dinners",
|
||||||
@@ -88,7 +88,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "list ready singular",
|
name: "list ready singular",
|
||||||
ev: ListReady(week, 1, 1),
|
ev: ListReady(week, 1, 1),
|
||||||
key: "list_ready:2026-10-12",
|
key: "list_ready:2026-10-12",
|
||||||
url: "/plan/shopping",
|
url: "/plan/shopping?week=2026-10-12",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's Supply run is ready",
|
title: "This week's Supply run is ready",
|
||||||
body: "1 item to buy for 1 dinner",
|
body: "1 item to buy for 1 dinner",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func NewService(settings *household.SettingsService, plans *plan.Service, sugges
|
|||||||
type Home struct {
|
type Home struct {
|
||||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||||
DateLabel string `json:"dateLabel"`
|
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;
|
// Tonight is the dinner planned for today on the current week's plan;
|
||||||
// nil when the week has no plan.
|
// nil when the week has no plan.
|
||||||
Tonight *Dinner `json:"tonight"`
|
Tonight *Dinner `json:"tonight"`
|
||||||
@@ -92,7 +95,8 @@ type RecentUpdates struct {
|
|||||||
Unread bool `json:"unread"`
|
Unread bool `json:"unread"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get reads the Home screen at the service's "now".
|
// Get reads the Home screen at the service's "now". It only reads: it
|
||||||
|
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||||
settings, err := s.settings.Get(ctx)
|
settings, err := s.settings.Get(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,7 +114,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Home{}, err
|
return Home{}, err
|
||||||
}
|
}
|
||||||
deck, err := s.suggest.Deck(ctx)
|
sideQuests, err := s.suggest.PendingCount(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||||
}
|
}
|
||||||
@@ -124,8 +128,9 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
|||||||
}
|
}
|
||||||
return Home{
|
return Home{
|
||||||
DateLabel: now.Format("Monday, Jan 2"),
|
DateLabel: now.Format("Monday, Jan 2"),
|
||||||
|
Timezone: settings.Timezone,
|
||||||
Tonight: tonight,
|
Tonight: tonight,
|
||||||
SideQuestCount: len(deck.Suggestions),
|
SideQuestCount: sideQuests,
|
||||||
ShoppingList: list,
|
ShoppingList: list,
|
||||||
RecentUpdates: RecentUpdates{Unread: unread},
|
RecentUpdates: RecentUpdates{Unread: unread},
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ func TestHomeEmpty(t *testing.T) {
|
|||||||
h := e.at(t, "2026-10-13 23:59")
|
h := e.at(t, "2026-10-13 23:59")
|
||||||
want := home.Home{
|
want := home.Home{
|
||||||
DateLabel: "Tuesday, Oct 13",
|
DateLabel: "Tuesday, Oct 13",
|
||||||
|
Timezone: zone,
|
||||||
ShoppingList: home.ShoppingStatus{
|
ShoppingList: home.ShoppingStatus{
|
||||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||||
},
|
},
|
||||||
@@ -289,6 +290,32 @@ func TestSideQuestCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading Home writes nothing: a batch still pending whose job is gone,
|
||||||
|
// which a Deck read would fail, is left as it was, and the read succeeds.
|
||||||
|
func TestGetDoesNotWriteBatches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
created := "2026-10-13T12:00:00.000Z"
|
||||||
|
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.Status != "pending" {
|
||||||
|
t.Fatalf("new batch status = %q, want pending", batch.Status)
|
||||||
|
}
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
|
||||||
|
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
|
||||||
|
}
|
||||||
|
var status, updatedAt string
|
||||||
|
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "pending" || updatedAt != created {
|
||||||
|
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The Supply run shown is the upcoming week's when it has one, else the
|
// 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.
|
// current week's; the weeks are the household's local weeks.
|
||||||
func TestShoppingStatus(t *testing.T) {
|
func TestShoppingStatus(t *testing.T) {
|
||||||
|
|||||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
p := r.Payload
|
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" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func TestGetHome(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||||
|
"timezone": loc.String(),
|
||||||
"tonight": nil,
|
"tonight": nil,
|
||||||
"sideQuestCount": 0,
|
"sideQuestCount": 0,
|
||||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
p := r.Payload
|
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" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
|||||||
return deck, nil
|
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
|
// 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
|
// batch's job when the decision called for one: the batch it started, or
|
||||||
// the one already pending or running that it joined.
|
// the one already pending or running that it joined.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||||
@@ -20,6 +20,7 @@ const NOTHING_YET = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,6 +73,31 @@ describe('HomePage', () => {
|
|||||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
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 () => {
|
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||||
renderHome(HOME)
|
renderHome(HOME)
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,45 @@ describe('RotationCheck', () => {
|
|||||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
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 () => {
|
it('opens Home with the card from a rotation_review push notification', async () => {
|
||||||
stubApi([SHAKSHUKA])
|
stubApi([SHAKSHUKA])
|
||||||
const origin = 'https://mealquest.test'
|
const origin = 'https://mealquest.test'
|
||||||
|
|||||||
@@ -44,17 +44,12 @@ function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
|||||||
// household answers it — nothing is decided for them. An answer is applied
|
// 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
|
// 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
|
// the answer is saved; the answers are locked until it settles. A failed
|
||||||
// answer puts its check back with an ErrorBanner.
|
// 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() {
|
export function RotationCheck() {
|
||||||
const reviews = useRotationReviews()
|
const reviews = useRotationReviews()
|
||||||
const decide = useDecideRotationReview()
|
const decide = useDecideRotationReview()
|
||||||
if (reviews.isError) {
|
|
||||||
return (
|
|
||||||
<ErrorBanner onRetry={() => void reviews.refetch()}>
|
|
||||||
Couldn't load the Rotation check. {reviews.error.message}
|
|
||||||
</ErrorBanner>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
|
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
|
||||||
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
||||||
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
||||||
@@ -65,6 +60,11 @@ export function RotationCheck() {
|
|||||||
{CONFIRMATION[answered.decision]}
|
{CONFIRMATION[answered.decision]}
|
||||||
</p>
|
</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>}
|
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
|
||||||
{next !== undefined && (
|
{next !== undefined && (
|
||||||
<RotationCard
|
<RotationCard
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const UPDATES = {
|
|||||||
type: 'plan_ready',
|
type: 'plan_ready',
|
||||||
title: 'Your week is drafted',
|
title: 'Your week is drafted',
|
||||||
body: 'Take a look at the Draft board.',
|
body: 'Take a look at the Draft board.',
|
||||||
url: '/plan',
|
url: '/plan?week=2026-10-12',
|
||||||
created_at: minutesAgo(5),
|
created_at: minutesAgo(5),
|
||||||
},
|
},
|
||||||
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||||
@@ -311,7 +311,7 @@ describe('Recent updates sheet', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, '/plan'],
|
[0, '/plan?week=2026-10-12'],
|
||||||
[1, '/discover'],
|
[1, '/discover'],
|
||||||
[2, '/plan/shopping?week=2026-10-05'],
|
[2, '/plan/shopping?week=2026-10-05'],
|
||||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { parseUpdates } from './api.ts'
|
import { HOME } from '../test/ApiProviders.tsx'
|
||||||
|
import { parseHome, parseUpdates } from './api.ts'
|
||||||
|
|
||||||
const origin = 'https://mealquest.example'
|
const origin = 'https://mealquest.example'
|
||||||
const where = 'GET /api/v1/updates'
|
const where = 'GET /api/v1/updates'
|
||||||
@@ -37,3 +38,15 @@ describe('parseUpdates', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseHome', () => {
|
||||||
|
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
|
||||||
|
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
|
||||||
|
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
|
||||||
|
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
|
||||||
|
)
|
||||||
|
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
|
||||||
|
'GET /api/v1/home: timezone undefined is not an IANA zone',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+35
-4
@@ -1,8 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { oneOf, photoId } from '../dinners/api.ts'
|
import { oneOf, photoId } from '../dinners/api.ts'
|
||||||
import { weekLabel } from '../plan/week.ts'
|
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
|
||||||
import { appPath } from '../push/notification.ts'
|
import { appPath } from '../push/notification.ts'
|
||||||
|
|
||||||
export const HOME_PATH = '/api/v1/home'
|
export const HOME_PATH = '/api/v1/home'
|
||||||
@@ -27,11 +28,13 @@ export type Tonight = { id: string; name: string; photo_id: string | undefined;
|
|||||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||||
|
|
||||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||||
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
|
||||||
|
// next day; tonight is null when today has no plan; sideQuestCount is how
|
||||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||||
// something not yet seen.
|
// something not yet seen.
|
||||||
export type Home = {
|
export type Home = {
|
||||||
dateLabel: string
|
dateLabel: string
|
||||||
|
timezone: string
|
||||||
tonight: Tonight | null
|
tonight: Tonight | null
|
||||||
sideQuestCount: number
|
sideQuestCount: number
|
||||||
shoppingList: ShoppingListStatus
|
shoppingList: ShoppingListStatus
|
||||||
@@ -77,6 +80,9 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
|||||||
// parseHome checks a GET /api/v1/home body.
|
// parseHome checks a GET /api/v1/home body.
|
||||||
export function parseHome(where: string, body: unknown): Home {
|
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 (!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) {
|
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,7 @@ export function parseHome(where: string, body: unknown): Home {
|
|||||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||||
return {
|
return {
|
||||||
dateLabel: body.dateLabel,
|
dateLabel: body.dateLabel,
|
||||||
|
timezone: body.timezone,
|
||||||
tonight: parseTonight(where, body.tonight),
|
tonight: parseTonight(where, body.tonight),
|
||||||
sideQuestCount: body.sideQuestCount as number,
|
sideQuestCount: body.sideQuestCount as number,
|
||||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||||
@@ -91,13 +98,37 @@ export function parseHome(where: string, body: unknown): Home {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// useHome is the Home screen's summary (GET /api/v1/home).
|
// isZone says whether zone is an IANA zone this browser knows.
|
||||||
|
function isZone(zone: string): boolean {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: zone })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
|
||||||
|
// again at the household's next local midnight, counted from each read, so
|
||||||
|
// a Home left open does not keep yesterday's date and dinner.
|
||||||
export function useHome() {
|
export function useHome() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
const queryClient = useQueryClient()
|
||||||
|
const home = useQuery({
|
||||||
queryKey: HOME_KEY,
|
queryKey: HOME_KEY,
|
||||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
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 const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||||
|
|||||||
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
// screen.
|
||||||
|
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={['/plan']}>
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/plan" element={<PlanPage />} />
|
<Route
|
||||||
|
path="/plan"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<PlanPage />
|
||||||
|
<Searched />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/plan/shopping" element={shopping} />
|
<Route path="/plan/shopping" element={shopping} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</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
|
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||||
// queryClient keeps.
|
// queryClient keeps.
|
||||||
function pushesKept(queryClient: QueryClient) {
|
function pushesKept(queryClient: QueryClient) {
|
||||||
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
|
|||||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
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 () => {
|
it('moves between weeks with the previous and next week buttons', async () => {
|
||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from '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 { ApiError, useJob } from '../api/index.ts'
|
||||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
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 { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import buttonStyles from '../ui/Button.module.css'
|
import buttonStyles from '../ui/Button.module.css'
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
dayOfMonth,
|
dayOfMonth,
|
||||||
defaultWeek,
|
defaultWeek,
|
||||||
|
isMonday,
|
||||||
localNow,
|
localNow,
|
||||||
weekdayKicker,
|
weekdayKicker,
|
||||||
weekdayName,
|
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
|
// 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
|
// 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
|
// 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'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
|
// 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.
|
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||||
export function PlanPage() {
|
export function PlanPage() {
|
||||||
const layout = useBreakpoint()
|
const layout = useBreakpoint()
|
||||||
const settings = useHouseholdSettings()
|
const settings = useHouseholdSettings()
|
||||||
const [week, setWeek] = useState<string | null>(null)
|
const [params, setParams] = useSearchParams()
|
||||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
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
|
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 = (
|
content = (
|
||||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
<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 { 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.
|
// 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' }
|
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||||
@@ -72,4 +84,20 @@ 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-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
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
|||||||
return addDays(date, -sinceMonday)
|
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".
|
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||||
export function weekdayKicker(date: string): string {
|
export function weekdayKicker(date: string): string {
|
||||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||||
@@ -115,3 +124,20 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
|||||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// untilLocalMidnight is how many milliseconds after instant now the next
|
||||||
|
// midnight comes in IANA zone. It counts from the zone's wall clock at now,
|
||||||
|
// so a change of UTC offset before that midnight moves it by the change;
|
||||||
|
// offsets change in the small hours, after midnight.
|
||||||
|
export function untilLocalMidnight(zone: string, now: Date): number {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: zone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(now)
|
||||||
|
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
||||||
|
const sinceMidnight = ((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getUTCMilliseconds()
|
||||||
|
return DAY_MS - sinceMidnight
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router'
|
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 { ApiError } from '../api/errors.ts'
|
||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.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())
|
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const GOOGLE_NOT_CONNECTED = {
|
|||||||
// unread update.
|
// unread update.
|
||||||
export const HOME = {
|
export const HOME = {
|
||||||
dateLabel: 'Friday, Oct 2',
|
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 },
|
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||||
sideQuestCount: 10,
|
sideQuestCount: 10,
|
||||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||||
|
|||||||
Reference in New Issue
Block a user