Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d572ee192d | ||
|
|
8e18c0b37c | ||
|
|
3a0459cce3 | ||
|
|
dbb6310112 |
+7
-1
@@ -23,6 +23,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
@@ -171,6 +172,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -187,7 +193,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1089,6 +1089,34 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
for any other `by`. A frozen list is read through `GET
|
for any other `by`. A frozen list is read through `GET
|
||||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||||
|
|
||||||
|
## Home API (Feature 21)
|
||||||
|
|
||||||
|
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||||||
|
services that own each value, at "now" in the household's zone (never
|
||||||
|
UTC), and answers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dateLabel": "Friday, Oct 2",
|
||||||
|
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||||
|
"sideQuestCount": 10,
|
||||||
|
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||||
|
"recentUpdates": {"unread": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||||
|
when the week has no plan; it changes at the household's local
|
||||||
|
midnight. `photo` and `minutes_total` are null when unknown.
|
||||||
|
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
|
||||||
|
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||||
|
the current week's (`shopping.Get`); with neither, it is the current
|
||||||
|
week with `ready` false. `recentUpdates.unread` is true when a
|
||||||
|
notification was stored in `notification_outbox` after
|
||||||
|
`household_settings.updates_seen_at`, or at all while that is null
|
||||||
|
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||||
|
never stored and never counts. Nothing writes `updates_seen_at` yet.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
|
|||||||
return i, err
|
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
|
const insertNotification = `-- name: InsertNotification :execrows
|
||||||
INSERT INTO notification_outbox (
|
INSERT INTO notification_outbox (
|
||||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ type Querier interface {
|
|||||||
// them only when a member edited them while it was grounding. Like
|
// them only when a member edited them while it was grounding. Like
|
||||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
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
|
// 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
|
// stored for the event key and channel, in any status, is left unchanged and
|
||||||
// no row is inserted.
|
// no row is inserted.
|
||||||
|
|||||||
@@ -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,197 @@
|
|||||||
|
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||||||
|
// swipe, the Supply run and whether there are recent updates. It owns no
|
||||||
|
// data: every value is read through the service that owns it, and "now"
|
||||||
|
// and "tonight" are in the household's zone, never UTC.
|
||||||
|
package home
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service reads the Home screen.
|
||||||
|
type Service struct {
|
||||||
|
settings *household.SettingsService
|
||||||
|
plans *plan.Service
|
||||||
|
suggest *suggest.Service
|
||||||
|
shopping *shopping.Service
|
||||||
|
notifier *notify.Service
|
||||||
|
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns a service reading through the given services.
|
||||||
|
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||||||
|
switch {
|
||||||
|
case settings == nil:
|
||||||
|
return nil, errors.New("home: nil household settings service")
|
||||||
|
case plans == nil:
|
||||||
|
return nil, errors.New("home: nil plan service")
|
||||||
|
case suggestSvc == nil:
|
||||||
|
return nil, errors.New("home: nil suggestions service")
|
||||||
|
case shop == nil:
|
||||||
|
return nil, errors.New("home: nil shopping service")
|
||||||
|
case notifier == nil:
|
||||||
|
return nil, errors.New("home: nil notifier")
|
||||||
|
}
|
||||||
|
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home is the Home screen as the API shows it.
|
||||||
|
type Home struct {
|
||||||
|
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||||
|
DateLabel string `json:"dateLabel"`
|
||||||
|
// Tonight is the dinner planned for today on the current week's plan;
|
||||||
|
// nil when the week has no plan.
|
||||||
|
Tonight *Dinner `json:"tonight"`
|
||||||
|
// SideQuestCount is how many Side quests are pending on the deck.
|
||||||
|
SideQuestCount int `json:"sideQuestCount"`
|
||||||
|
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||||||
|
// else of the current week.
|
||||||
|
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||||||
|
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dinner is tonight's dinner.
|
||||||
|
type Dinner struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Photo *string `json:"photo"`
|
||||||
|
// Origin is regular or side_quest.
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
MinutesTotal *int64 `json:"minutes_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShoppingStatus is whether a week's Supply run is ready.
|
||||||
|
type ShoppingStatus struct {
|
||||||
|
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||||||
|
WeekStart string `json:"weekStart"`
|
||||||
|
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||||||
|
Label string `json:"label"`
|
||||||
|
// Ready is true when the week has a Supply run.
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||||||
|
// until it is, and when there is no Supply run.
|
||||||
|
SentToChatAt *string `json:"sentToChatAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentUpdates says whether there is something new for the bell.
|
||||||
|
type RecentUpdates struct {
|
||||||
|
// Unread is true when a notification was stored after the household
|
||||||
|
// last saw its updates (household_settings.updates_seen_at), or at all
|
||||||
|
// when it never has.
|
||||||
|
Unread bool `json:"unread"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reads the Home screen at the service's "now".
|
||||||
|
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||||
|
settings, err := s.settings.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||||||
|
}
|
||||||
|
zone, err := household.LoadZone(settings.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||||||
|
}
|
||||||
|
now := s.now().In(zone)
|
||||||
|
today := household.Today(now, zone)
|
||||||
|
thisWeek := household.WeekStart(today)
|
||||||
|
|
||||||
|
tonight, err := s.tonight(ctx, thisWeek, today)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, err
|
||||||
|
}
|
||||||
|
deck, err := s.suggest.Deck(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||||
|
}
|
||||||
|
list, err := s.shoppingStatus(ctx, thisWeek)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, err
|
||||||
|
}
|
||||||
|
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return Home{}, err
|
||||||
|
}
|
||||||
|
return Home{
|
||||||
|
DateLabel: now.Format("Monday, Jan 2"),
|
||||||
|
Tonight: tonight,
|
||||||
|
SideQuestCount: len(deck.Suggestions),
|
||||||
|
ShoppingList: list,
|
||||||
|
RecentUpdates: RecentUpdates{Unread: unread},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tonight returns the dinner planned on today in the plan of the week
|
||||||
|
// starting thisWeek, or nil when the week has no plan.
|
||||||
|
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||||||
|
p, err := s.plans.Get(ctx, thisWeek)
|
||||||
|
if errors.Is(err, plan.ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("home: read plan: %w", err)
|
||||||
|
}
|
||||||
|
for _, d := range p.Days {
|
||||||
|
if d.Date == today.String() {
|
||||||
|
return &Dinner{
|
||||||
|
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||||||
|
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||||||
|
// it has one, else of the current week's, ready or not.
|
||||||
|
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||||||
|
y, m, d := thisWeek.Date()
|
||||||
|
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||||
|
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||||||
|
l, err := s.shopping.Get(ctx, week)
|
||||||
|
if errors.Is(err, shopping.ErrListNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||||||
|
}
|
||||||
|
return weekStatus(week, true, l.SentToChatAt)
|
||||||
|
}
|
||||||
|
return weekStatus(thisWeek, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||||||
|
label, err := household.WeekLabel(week)
|
||||||
|
if err != nil {
|
||||||
|
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||||||
|
}
|
||||||
|
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread reports whether a notification was stored after seenAt, the
|
||||||
|
// stored updates_seen_at, or at all when it is nil.
|
||||||
|
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||||
|
var since *time.Time
|
||||||
|
if seenAt != nil {
|
||||||
|
var ts store.Timestamp
|
||||||
|
if err := ts.Scan(*seenAt); err != nil {
|
||||||
|
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
since = &ts.Time
|
||||||
|
}
|
||||||
|
unread, err := s.notifier.StoredSince(ctx, since)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("home: %w", err)
|
||||||
|
}
|
||||||
|
return unread, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package home_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zone is the household's zone in these tests: not UTC, so a late local
|
||||||
|
// evening is already the next day in UTC.
|
||||||
|
const zone = "America/Chicago"
|
||||||
|
|
||||||
|
// weekDates is the week of 2026-10-12, Monday first.
|
||||||
|
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
svc *home.Service
|
||||||
|
st *store.Store
|
||||||
|
notifier *notify.Service
|
||||||
|
loc *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEnv returns a home service over a migrated temp database whose
|
||||||
|
// household is in zone, reading "now" from a clock set to at.
|
||||||
|
func newEnv(t *testing.T) *env {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
st, err := store.Open(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
notifier := notifytest.New(t, st)
|
||||||
|
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||||
|
|
||||||
|
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||||||
|
googleSvc, err := gcal.NewService(st, box, gcal.Config{
|
||||||
|
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
|
||||||
|
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
|
||||||
|
CalendarAPI: google.CalendarAPI(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||||
|
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
|
return skills.SideQuests{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||||
|
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := home.NewService(household.NewSettingsService(st), plans, suggestSvc, shop, notifier)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(zone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &env{svc: svc, st: st, notifier: notifier, loc: loc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// at reads the Home screen with the clock at the household-local wall
|
||||||
|
// time "YYYY-MM-DD HH:MM".
|
||||||
|
func (e *env) at(t *testing.T, local string) home.Home {
|
||||||
|
t.Helper()
|
||||||
|
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home.SetClock(e.svc, func() time.Time { return now })
|
||||||
|
h, err := e.svc.Get(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// dinner stores a dinner and returns its id.
|
||||||
|
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
|
||||||
|
t.Helper()
|
||||||
|
now := store.Now().String()
|
||||||
|
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||||
|
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
|
||||||
|
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||||
|
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
|
||||||
|
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
|
||||||
|
// unknown minutes. It returns the plan's id.
|
||||||
|
func (e *env) planWeek(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
planID := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, date := range weekDates {
|
||||||
|
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
|
||||||
|
if date == "2026-10-14" {
|
||||||
|
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||||
|
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return planID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tonight's dinner and the date label follow the household's local
|
||||||
|
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
|
||||||
|
// Wednesday in UTC, and Home still shows Tuesday's dinner.
|
||||||
|
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
e.planWeek(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
local, label, dinner string
|
||||||
|
}{
|
||||||
|
// 23:59 CDT is 04:59 UTC the next day.
|
||||||
|
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
|
||||||
|
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
|
||||||
|
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
|
||||||
|
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
|
||||||
|
} {
|
||||||
|
h := e.at(t, tc.local)
|
||||||
|
if h.DateLabel != tc.label {
|
||||||
|
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
|
||||||
|
}
|
||||||
|
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
|
||||||
|
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late Sunday evening of the week before, the plan's week has not
|
||||||
|
// begun locally, though in UTC it has.
|
||||||
|
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
|
||||||
|
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tonight carries the dinner's id, name, photo, origin and minutes.
|
||||||
|
func TestTonightDinner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
e.planWeek(t)
|
||||||
|
|
||||||
|
h := e.at(t, "2026-10-13 18:00")
|
||||||
|
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||||
|
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||||
|
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||||
|
}
|
||||||
|
h = e.at(t, "2026-10-14 18:00")
|
||||||
|
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||||
|
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no plan, no suggestions, no Supply run and no notifications, Home
|
||||||
|
// says so: no dinner tonight, nothing to swipe, the current week's Supply
|
||||||
|
// run not ready and nothing unread.
|
||||||
|
func TestHomeEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
|
||||||
|
h := e.at(t, "2026-10-13 23:59")
|
||||||
|
want := home.Home{
|
||||||
|
DateLabel: "Tuesday, Oct 13",
|
||||||
|
ShoppingList: home.ShoppingStatus{
|
||||||
|
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if h != want {
|
||||||
|
t.Errorf("home = %+v, want %+v", h, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Side quest count is the pending cards of the deck: a decided
|
||||||
|
// suggestion is not counted.
|
||||||
|
func TestSideQuestCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
|
||||||
|
id := e.dinner(t, name, "suggested", "side_quest", 0)
|
||||||
|
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||||
|
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
|
||||||
|
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
|
||||||
|
}
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
|
||||||
|
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Supply run shown is the upcoming week's when it has one, else the
|
||||||
|
// current week's; the weeks are the household's local weeks.
|
||||||
|
func TestShoppingStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
planID := e.planWeek(t)
|
||||||
|
list := func(weekStart string, sent sql.NullString) {
|
||||||
|
t.Helper()
|
||||||
|
now := store.Now().String()
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx,
|
||||||
|
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
|
||||||
|
store.NewID(), weekStart, planID, sent, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list("2026-10-12", sql.NullString{})
|
||||||
|
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
|
||||||
|
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
|
||||||
|
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := "2026-10-17T13:00:00.000Z"
|
||||||
|
list("2026-10-19", sql.NullString{String: sent, Valid: true})
|
||||||
|
h := e.at(t, "2026-10-16 09:00")
|
||||||
|
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
|
||||||
|
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late Sunday evening locally is Monday in UTC: the current week is
|
||||||
|
// still the week of 2026-10-12, so its upcoming week's list shows.
|
||||||
|
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
|
||||||
|
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
|
||||||
|
}
|
||||||
|
// On Monday the week of 2026-10-19 is current and has no upcoming
|
||||||
|
// list.
|
||||||
|
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
|
||||||
|
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
|
||||||
|
}
|
||||||
|
// A week later neither the current nor the upcoming week has a list.
|
||||||
|
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
|
||||||
|
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
|
||||||
|
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent updates are unread when a notification was stored after the
|
||||||
|
// household last saw its updates, or ever when it never has.
|
||||||
|
func TestRecentUpdatesUnread(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||||
|
t.Error("unread with no notifications")
|
||||||
|
}
|
||||||
|
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||||
|
t.Error("not unread with a notification never seen")
|
||||||
|
}
|
||||||
|
seen := func(at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen(time.Now().Add(time.Hour))
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||||
|
t.Error("unread with every notification seen")
|
||||||
|
}
|
||||||
|
seen(time.Now().Add(-time.Hour))
|
||||||
|
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||||
|
t.Error("not unread with a notification stored after updates were seen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServiceRequiresServices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
|
||||||
|
t.Fatal("NewService accepted nil services")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,22 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StoredSince reports whether a notification was stored after since, or
|
||||||
|
// at all when since is nil. An event counts once it is stored for any
|
||||||
|
// channel, whatever became of its delivery; one whose preference switches
|
||||||
|
// every channel off is never stored and never counts.
|
||||||
|
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
|
||||||
|
var after sql.NullString
|
||||||
|
if since != nil {
|
||||||
|
after = sql.NullString{String: timestamp(*since), Valid: true}
|
||||||
|
}
|
||||||
|
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||||
// preference of ev's type.
|
// preference of ev's type.
|
||||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
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)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
|
|||||||
func TestNewRouterRequiresChat(t *testing.T) {
|
func TestNewRouterRequiresChat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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}))
|
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),
|
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)}})
|
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getHome answers GET /home with the Home screen at the household's local
|
||||||
|
// "now".
|
||||||
|
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h, err := svc.Get(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), "read home", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||||
|
// household (America/New_York) has no plan, no suggestions, no Supply run
|
||||||
|
// and no notifications.
|
||||||
|
func TestGetHome(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newTestRouter(t, "dev")
|
||||||
|
loc, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// want is the answer at instant now; the request is read between two
|
||||||
|
// instants, so it matches the answer at one of them.
|
||||||
|
want := func(now time.Time) string {
|
||||||
|
week := household.WeekStart(household.Today(now, loc))
|
||||||
|
label, err := household.WeekLabel(week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||||
|
"tonight": nil,
|
||||||
|
"sideQuestCount": 0,
|
||||||
|
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||||
|
"recentUpdates": map[string]any{"unread": false},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
before := time.Now()
|
||||||
|
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||||
|
after := time.Now()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(canonical) != want(before) && string(canonical) != want(after) {
|
||||||
|
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHomeNeedsSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil push service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"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/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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
@@ -66,12 +67,12 @@ type RouteMounter interface {
|
|||||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
// /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
|
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||||
// planSvc too, /cook-tonight; runner serves
|
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
|
||||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// 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.
|
// 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 {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
return nil, errors.New("server: nil household settings service")
|
||||||
}
|
}
|
||||||
@@ -105,6 +106,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if ideasSvc == nil {
|
if ideasSvc == nil {
|
||||||
return nil, errors.New("server: nil ideas service")
|
return nil, errors.New("server: nil ideas service")
|
||||||
}
|
}
|
||||||
|
if homeSvc == nil {
|
||||||
|
return nil, errors.New("server: nil home service")
|
||||||
|
}
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -137,6 +141,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
sub.NotFound(notFound)
|
sub.NotFound(notFound)
|
||||||
}
|
}
|
||||||
apiV1.Get("/me", me(logger))
|
apiV1.Get("/me", me(logger))
|
||||||
|
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||||
apiV1.Get("/settings/notifications", getNotificationSettings(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/aisettings"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
"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/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/dinners"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
@@ -56,7 +58,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -257,6 +259,65 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestHome returns a home service whose services all share one
|
||||||
|
// migrated temp database with zone America/New_York, with an unstarted job
|
||||||
|
// runner, no model, and Google and Google Chat endpoints that refuse every
|
||||||
|
// connection. One database keeps the routers that never ask for Home cheap.
|
||||||
|
func newTestHome(t *testing.T) *home.Service {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
notifier := notifytest.New(t, st)
|
||||||
|
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||||
|
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
|
||||||
|
ClientID: "client-id",
|
||||||
|
ClientSecret: "client-secret",
|
||||||
|
PublicURL: pub,
|
||||||
|
SecretKey: []byte("server-secret-key"),
|
||||||
|
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
||||||
|
RevokeURL: "http://127.0.0.1:1/revoke",
|
||||||
|
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||||
|
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
|
return skills.SideQuests{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||||
// asked to title stays untitled.
|
// asked to title stays untitled.
|
||||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||||
@@ -317,7 +378,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +387,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,7 +396,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +405,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +414,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil dinners service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +423,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +432,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil plan service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,7 +441,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil shopping service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,16 +450,25 @@ func TestNewRouterRequiresIdeas(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
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) {
|
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +477,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
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))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,3 +66,11 @@ SELECT * FROM notification_prefs;
|
|||||||
UPDATE notification_prefs
|
UPDATE notification_prefs
|
||||||
SET push = ?, chat = ?, updated_at = ?
|
SET push = ?, chat = ?, updated_at = ?
|
||||||
WHERE event = ?;
|
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;
|
||||||
|
|||||||
+22
-8
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
|||||||
import { mockViewport } from './test/matchMedia.ts'
|
import { mockViewport } from './test/matchMedia.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||||
import App, { routes } from './App.tsx'
|
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 { InDataRouter } from './test/dataRouter.tsx'
|
||||||
import { dinnerBody } from './test/dinners.ts'
|
import { dinnerBody } from './test/dinners.ts'
|
||||||
import { defaultWeek } from './plan/week.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.
|
// The routes that render a built screen rather than a placeholder page.
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
|
'/': true,
|
||||||
'/onboarding': true,
|
'/onboarding': true,
|
||||||
'/plan': true,
|
'/plan': true,
|
||||||
'/plan/shopping': true,
|
'/plan/shopping': true,
|
||||||
@@ -85,6 +86,16 @@ describe('App routes', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it('/ renders the Home screen inside the shell', async () => {
|
||||||
|
mockViewport(390)
|
||||||
|
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||||
|
renderAt('/')
|
||||||
|
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||||
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('/settings renders the settings home inside the shell', async () => {
|
it('/settings renders the settings home inside the shell', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({
|
stubFetchByPath({
|
||||||
@@ -262,12 +273,13 @@ describe('App bootstrap', () => {
|
|||||||
|
|
||||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
|
||||||
renderAt('/')
|
renderAt('/')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||||
screen.getByRole('button', { name: 'Retry' }).click()
|
screen.getByRole('button', { name: 'Retry' }).click()
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,9 +294,9 @@ describe('App offline bootstrap', () => {
|
|||||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||||
// member, then the app is closed.
|
// member, then the app is closed.
|
||||||
async function visitOnline() {
|
async function visitOnline() {
|
||||||
stubFetchByPath(SIGNED_IN)
|
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||||
const { unmount } = renderAt('/')
|
const { unmount } = renderAt('/')
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||||
unmount()
|
unmount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,14 +307,16 @@ describe('App offline bootstrap', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// A test that emits an offline event leaves TanStack Query's online
|
// 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(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
|
||||||
window.dispatchEvent(new Event('online'))
|
window.dispatchEvent(new Event('online'))
|
||||||
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/', 'Home'],
|
['/', "Tonight's quest"],
|
||||||
['/settings/notifications', 'Notifications'],
|
['/settings/notifications', 'Notifications'],
|
||||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
|||||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||||
|
import { HomePage } from './home/HomePage.tsx'
|
||||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.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.
|
// The screens built so far; every other route shows its placeholder page.
|
||||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||||
|
'/': <HomePage />,
|
||||||
'/plan': <PlanPage />,
|
'/plan': <PlanPage />,
|
||||||
'/plan/shopping': <SupplyRunPage />,
|
'/plan/shopping': <SupplyRunPage />,
|
||||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
'/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,288 @@
|
|||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
|
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||||
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { HOME_PATH } from './api.ts'
|
||||||
|
import { HomePage } from './HomePage.tsx'
|
||||||
|
|
||||||
|
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||||
|
// Supply run and nothing unread.
|
||||||
|
const NOTHING_YET = {
|
||||||
|
...HOME,
|
||||||
|
tonight: null,
|
||||||
|
sideQuestCount: 0,
|
||||||
|
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
|
||||||
|
recentUpdates: { unread: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Where shows the route a link went to.
|
||||||
|
function Where() {
|
||||||
|
const location = useLocation()
|
||||||
|
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHome(body: unknown) {
|
||||||
|
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
|
||||||
|
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('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 () => {
|
||||||
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
|
||||||
|
renderRouter()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||||
|
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a home it cannot read rather than guessing', async () => {
|
||||||
|
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
|
||||||
|
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
`tonight's minutes_total ${minutes} is not a positive whole number`,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
|
||||||
|
const { minutes_total: _, ...tonight } = HOME.tonight
|
||||||
|
renderHome({ ...HOME, tonight })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a missing', undefined],
|
||||||
|
['an empty', ''],
|
||||||
|
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
|
||||||
|
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
|
||||||
|
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
|
||||||
|
)
|
||||||
|
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a missing', undefined],
|
||||||
|
['an empty', ''],
|
||||||
|
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
|
||||||
|
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
|
||||||
|
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
|
||||||
|
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
|
||||||
|
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||||
|
import { Link, useNavigate } from 'react-router'
|
||||||
|
import { duration } from '../plan/PlanPage.tsx'
|
||||||
|
import { supplyRunScreen } from '../shopping/api.ts'
|
||||||
|
import buttonStyles from '../ui/Button.module.css'
|
||||||
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
|
||||||
|
const ORIGIN_LABEL: Record<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.
|
||||||
|
//
|
||||||
|
// The bell only shows whether there are unread updates; the Recent updates
|
||||||
|
// sheet it opens is still to come.
|
||||||
|
export function HomePage() {
|
||||||
|
const home = useHome()
|
||||||
|
const data = home.data
|
||||||
|
return (
|
||||||
|
<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}>
|
||||||
|
<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} />)}
|
||||||
|
<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} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useApi } from '../api/index.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
import { oneOf, photoId } from '../dinners/api.ts'
|
||||||
|
import { weekLabel } from '../plan/week.ts'
|
||||||
|
|
||||||
|
export const HOME_PATH = '/api/v1/home'
|
||||||
|
export const HOME_KEY = ['home'] as const
|
||||||
|
|
||||||
|
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||||
|
export type Origin = (typeof ORIGINS)[number]
|
||||||
|
|
||||||
|
// Tonight is the dinner planned for today: photo_id is its stored photo's
|
||||||
|
// id, from photo, and minutes_total its minutes from prep to table, a
|
||||||
|
// positive whole number, or null when the recipe does not say.
|
||||||
|
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
|
||||||
|
|
||||||
|
// ShoppingListStatus is the Supply run Home points at: next week's once it
|
||||||
|
// has one, otherwise this week's, ready when that week has a list.
|
||||||
|
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
|
||||||
|
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
|
||||||
|
// Oct 5 – 11").
|
||||||
|
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||||
|
|
||||||
|
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||||
|
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
||||||
|
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||||
|
// something not yet seen.
|
||||||
|
export type Home = {
|
||||||
|
dateLabel: string
|
||||||
|
tonight: Tonight | null
|
||||||
|
sideQuestCount: number
|
||||||
|
shoppingList: ShoppingListStatus
|
||||||
|
unread: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTonight(where: string, body: unknown): Tonight | null {
|
||||||
|
if (body === null) return null
|
||||||
|
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||||
|
throw new Error(`${where}: tonight is not a dinner`)
|
||||||
|
}
|
||||||
|
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
|
||||||
|
const minutes = body.minutes_total
|
||||||
|
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
|
||||||
|
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
photo_id: photoId(where, body.photo),
|
||||||
|
origin: oneOf(where, 'origin', ORIGINS, body.origin),
|
||||||
|
minutes_total: minutes as number | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
|
||||||
|
// list, ready or not, must carry the API's label for its week, which must be
|
||||||
|
// the label weekStart has, since the status row shows dates from weekStart.
|
||||||
|
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||||
|
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
|
||||||
|
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
|
||||||
|
}
|
||||||
|
const week = weekLabel(list.weekStart)
|
||||||
|
if (typeof list.label !== 'string' || list.label === '') {
|
||||||
|
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
|
||||||
|
}
|
||||||
|
if (list.label !== `${week.week} · ${week.dates}`) {
|
||||||
|
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
|
||||||
|
}
|
||||||
|
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHome checks a GET /api/v1/home body.
|
||||||
|
export function parseHome(where: string, body: unknown): Home {
|
||||||
|
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||||
|
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||||
|
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||||
|
}
|
||||||
|
const updates = body.recentUpdates
|
||||||
|
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||||
|
return {
|
||||||
|
dateLabel: body.dateLabel,
|
||||||
|
tonight: parseTonight(where, body.tonight),
|
||||||
|
sideQuestCount: body.sideQuestCount as number,
|
||||||
|
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||||
|
unread: updates.unread,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useHome is the Home screen's summary (GET /api/v1/home).
|
||||||
|
export function useHome() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: HOME_KEY,
|
||||||
|
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
|||||||
import App from '../App.tsx'
|
import App from '../App.tsx'
|
||||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
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 { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
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 path = String(input)
|
||||||
const method = init?.method ?? 'GET'
|
const method = init?.method ?? 'GET'
|
||||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
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 === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||||
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||||
subscriptions.push(JSON.parse(String(init?.body)))
|
subscriptions.push(JSON.parse(String(init?.body)))
|
||||||
@@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) {
|
|||||||
<AuthBootstrap>
|
<AuthBootstrap>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
<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>} />
|
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthBootstrap>
|
</AuthBootstrap>
|
||||||
@@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
|||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||||
completed(server.held[0]!)
|
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() {
|
function useIphoneSafariTab() {
|
||||||
@@ -210,7 +211,7 @@ describe('onboarding step 3', () => {
|
|||||||
expect(later()).toBeDisabled()
|
expect(later()).toBeDisabled()
|
||||||
completed(server.held[0]!)
|
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(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||||
expect(requestPermission).not.toHaveBeenCalled()
|
expect(requestPermission).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -314,7 +315,7 @@ describe('onboarding step 3', () => {
|
|||||||
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||||
completed(server.held[0]!)
|
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.notification.requestPermission).not.toHaveBeenCalled()
|
||||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
expect(server.subscriptions).toEqual([])
|
expect(server.subscriptions).toEqual([])
|
||||||
@@ -358,7 +359,7 @@ describe('onboarding step 3', () => {
|
|||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||||
completed(server.held[1]!)
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ export const GOOGLE_NOT_CONNECTED = {
|
|||||||
last_sync_error: null,
|
last_sync_error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a
|
||||||
|
// Regular, tonight; ten Side quests; next week's Supply run ready; an
|
||||||
|
// unread update.
|
||||||
|
export const HOME = {
|
||||||
|
dateLabel: 'Friday, Oct 2',
|
||||||
|
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||||
|
sideQuestCount: 10,
|
||||||
|
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||||
|
recentUpdates: { unread: true },
|
||||||
|
}
|
||||||
|
|
||||||
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
||||||
// seed channels and reminder time.
|
// seed channels and reminder time.
|
||||||
export const NOTIFICATION_SETTINGS = {
|
export const NOTIFICATION_SETTINGS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user