Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a2b611c8 | ||
|
|
955613941f | ||
|
|
dba4b93280 | ||
|
|
3aef21922b | ||
|
|
9b9421bb58 | ||
|
|
0a47b26af6 | ||
|
|
2ed5f69783 | ||
|
|
09456c48a2 |
+21
-2
@@ -23,6 +23,7 @@ import (
|
|||||||
"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/household"
|
"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/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
@@ -112,6 +113,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger)
|
||||||
|
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
|
||||||
@@ -128,7 +134,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, dinnerSvc, suggestSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -253,7 +259,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
|||||||
// registerJobKinds registers the framework's job kinds on runner. A feature
|
// registerJobKinds registers the framework's job kinds on runner. A feature
|
||||||
// service registers its own kinds when it is built, before the runner
|
// service registers its own kinds when it is built, before the runner
|
||||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||||
// suggest.NewService suggest.KindSuggestionBatch).
|
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||||
|
// plan.KindDraftWeek).
|
||||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||||
builtins, err := jobs.RegisterBuiltins(runner)
|
builtins, err := jobs.RegisterBuiltins(runner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -286,6 +293,18 @@ func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weekDrafter runs draft_week with the AI settings current at each run,
|
||||||
|
// sharing guard.
|
||||||
|
func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
|
||||||
|
return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||||
|
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||||
|
if err != nil {
|
||||||
|
return skills.Week{}, err
|
||||||
|
}
|
||||||
|
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// logListening logs the startup line. Only the public URL's origin is logged
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
+60
-2
@@ -99,9 +99,9 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||||
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
|
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
|
||||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
|
||||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
|
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
|
||||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
|
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||||
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
|
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
|
||||||
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
|
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
|
||||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||||
@@ -334,6 +334,64 @@ automatic** (D-021).
|
|||||||
the decision's own transaction, so a decision is never recorded without
|
the decision's own transaction, so a decision is never recorded without
|
||||||
the replenish it calls for; when they cannot be written, neither is the
|
the replenish it calls for; when they cannot be written, neither is the
|
||||||
decision, and a retry decides again.
|
decision, and a retry decides again.
|
||||||
|
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
|
||||||
|
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
|
||||||
|
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
|
||||||
|
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
|
||||||
|
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
|
||||||
|
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
|
||||||
|
the dinner was planned from the Quest log; `start_by` is the household's
|
||||||
|
dinner time on that date minus `minutes_total`, computed on read, for a
|
||||||
|
crock-pot dinner with known minutes, else null. `POST
|
||||||
|
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
|
||||||
|
`{jobId}` for a `draft_week` job, or for the one already queued or
|
||||||
|
running for the week, so a week has at most one draft in flight. A
|
||||||
|
confirmed draft while the week's job was asked for without confirm is
|
||||||
|
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
|
||||||
|
and every Quest log dinner no other plan for the current or a later week
|
||||||
|
has, with the household context and last week's dinners, and stores the
|
||||||
|
week: a new plan is `draft`; a redraft keeps each day's
|
||||||
|
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
|
||||||
|
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
|
||||||
|
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
|
||||||
|
left as it is and the job fails `confirm_required`. The job reads the
|
||||||
|
plan's revision before draft_week runs; if the plan was edited (a swap,
|
||||||
|
or another draft stored) by the time the draft is stored, the plan is
|
||||||
|
left as it is and the job fails `plan_changed`. The pool is read again
|
||||||
|
in the same transaction that stores the draft; if a dinner the draft
|
||||||
|
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
|
||||||
|
stored on another current or later week's plan, as by a draft of
|
||||||
|
another week), nothing is stored and the job fails `pool_changed`,
|
||||||
|
which drafting again resolves. A pool under seven
|
||||||
|
dinners fails the job `not_enough_dinners`, draft_week failing
|
||||||
|
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
|
||||||
|
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
|
||||||
|
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
|
||||||
|
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||||||
|
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||||||
|
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||||
|
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
|
||||||
|
`rotation_check` schedule calls for the household-local day just past,
|
||||||
|
moves every `quest_log` dinner planned on that date, on a plan of any
|
||||||
|
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
||||||
|
a dinner already checked for that date is left as it is, so it is
|
||||||
|
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||||
|
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||||
|
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||||
|
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||||
|
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
|
||||||
|
("We didn't make it") returns it to the Quest log; its origin is kept.
|
||||||
|
The check is closed with `decided_at` and the session's `updated_by`,
|
||||||
|
and the answer is `{dinner, plan_date, decision, dinner_status,
|
||||||
|
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
|
||||||
|
an unknown dinner or a dinner never checked 404 `not_found`, a dinner
|
||||||
|
whose checks are all decided (including a second decision) 409
|
||||||
|
`no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
|
||||||
|
Keep is the only path from tried to Regular. A drop records the
|
||||||
|
dinner's name on the check (`rotation_reviews.name`), and that name
|
||||||
|
joins the passed names (`ListPassedNames`) the Side quest dedupe and
|
||||||
|
household context use, so it is never suggested again, even after the
|
||||||
|
dinner is renamed.
|
||||||
- `/health` — liveness.
|
- `/health` — liveness.
|
||||||
- Everything else serves the embedded PWA with SPA fallback.
|
- Everything else serves the embedded PWA with SPA fallback.
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PassedLimit is how many of the most recently passed Side quests the
|
// PassedLimit is how many of the most recently passed names (Side quests
|
||||||
// household context holds.
|
// passed and dinners a Rotation check dropped) the household context holds.
|
||||||
const PassedLimit = 60
|
const PassedLimit = 60
|
||||||
|
|
||||||
// PlanWeeks is how many ISO weeks of plans, ending with the current week,
|
// PlanWeeks is how many ISO weeks of plans, ending with the current week,
|
||||||
@@ -34,8 +34,8 @@ type HouseholdContext struct {
|
|||||||
Regulars []RegularDinner
|
Regulars []RegularDinner
|
||||||
// QuestLog holds the Quest log dinner names, in name order.
|
// QuestLog holds the Quest log dinner names, in name order.
|
||||||
QuestLog []string
|
QuestLog []string
|
||||||
// Passed holds the names of the most recently passed Side quests,
|
// Passed holds the most recently passed names, oldest first: Side
|
||||||
// oldest first.
|
// quests passed and dinners a Rotation check dropped ("Not for us").
|
||||||
Passed []string
|
Passed []string
|
||||||
// Plans are the planned dinners of the last PlanWeeks weeks, oldest
|
// Plans are the planned dinners of the last PlanWeeks weeks, oldest
|
||||||
// first.
|
// first.
|
||||||
@@ -137,7 +137,7 @@ func readContext(ctx context.Context, st *store.Store, now time.Time) (h Househo
|
|||||||
h.QuestLog = append(h.QuestLog, d.Name)
|
h.QuestLog = append(h.QuestLog, d.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit)
|
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return h, fmt.Errorf("read passed Side quests: %w", err)
|
return h, fmt.Errorf("read passed Side quests: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
// Aliased: the package tests name a fixture household.
|
||||||
|
hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteLimit is the most characters a drafted week's note may have.
|
||||||
|
const NoteLimit = 160
|
||||||
|
|
||||||
|
// WeekLength is how many dinners a drafted week holds, one a day from
|
||||||
|
// Monday to Sunday.
|
||||||
|
const WeekLength = 7
|
||||||
|
|
||||||
|
// Candidate kinds: a Regular or a Quest log dinner.
|
||||||
|
const (
|
||||||
|
KindRegular = "regular"
|
||||||
|
KindQuestLog = "quest_log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeekRequest is what draft_week reads.
|
||||||
|
type WeekRequest struct {
|
||||||
|
// Context is the household context (see harness.BuildContext).
|
||||||
|
Context harness.HouseholdContext
|
||||||
|
// WeekStart is the Monday of the week to draft, household-local.
|
||||||
|
WeekStart store.LocalDate
|
||||||
|
// Pool holds the dinners the week may be drafted from. It must hold at
|
||||||
|
// least WeekLength dinners, each once.
|
||||||
|
Pool []Candidate
|
||||||
|
// LastWeek holds the IDs of the dinners planned in the week before
|
||||||
|
// WeekStart. They need not be in Pool.
|
||||||
|
LastWeek []string
|
||||||
|
// DinnerTime is the household's dinner time, "HH:MM", from which a
|
||||||
|
// crock-pot dinner's StartBy is computed.
|
||||||
|
DinnerTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate is one dinner of the pool.
|
||||||
|
type Candidate struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
// Kind is KindRegular or KindQuestLog.
|
||||||
|
Kind string
|
||||||
|
// Method is one of stovetop, oven, crockpot, grill and other, or ""
|
||||||
|
// when unknown.
|
||||||
|
Method string
|
||||||
|
// Minutes from prep to table, or 0 when unknown.
|
||||||
|
Minutes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek
|
||||||
|
// returns it before any LLM call.
|
||||||
|
type NotEnoughDinnersError struct {
|
||||||
|
// Pool is how many dinners the pool holds.
|
||||||
|
Pool int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NotEnoughDinnersError) Error() string {
|
||||||
|
return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Week is draft_week's output.
|
||||||
|
type Week struct {
|
||||||
|
// Days are the week's dinners, Monday to Sunday.
|
||||||
|
Days []WeekDay `json:"days"`
|
||||||
|
// Note is a short word on the week, at most NoteLimit characters. It
|
||||||
|
// may be empty.
|
||||||
|
Note string `json:"note"`
|
||||||
|
// Warnings say what the draft did that the rules discourage but allow,
|
||||||
|
// such as repeating last week's dinners. It is never part of the
|
||||||
|
// model's reply.
|
||||||
|
Warnings []string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekDay is one day's dinner.
|
||||||
|
type WeekDay struct {
|
||||||
|
// Date is the household-local date, YYYY-MM-DD.
|
||||||
|
Date string `json:"date"`
|
||||||
|
// DinnerID is the ID of a dinner of the pool.
|
||||||
|
DinnerID string `json:"dinner_id"`
|
||||||
|
// IsNew is true for a Quest log dinner. It is computed from the pool,
|
||||||
|
// never part of the model's reply.
|
||||||
|
IsNew bool `json:"-"`
|
||||||
|
// StartBy is when a crock-pot dinner must start to be ready at the
|
||||||
|
// household's dinner time; it is zero for any other dinner and for a
|
||||||
|
// crock-pot dinner whose minutes are unknown. It is computed, never
|
||||||
|
// part of the model's reply.
|
||||||
|
StartBy time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DraftWeek runs draft_week v1 on in and returns a week of seven dinners
|
||||||
|
// from the pool, one a day from Monday to Sunday, none twice. A pool of
|
||||||
|
// fewer than WeekLength dinners fails with *NotEnoughDinnersError before
|
||||||
|
// any LLM call, and any other invalid request fails before it too. scope
|
||||||
|
// names the week (see harness.Run).
|
||||||
|
func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) {
|
||||||
|
s, err := newDraftWeek(in)
|
||||||
|
if err != nil {
|
||||||
|
return Week{}, err
|
||||||
|
}
|
||||||
|
return harness.Run(ctx, cfg, s, scope, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftWeek is the skill. It holds its input, the week's dates and the
|
||||||
|
// pool by ID because validation and post-processing compare the output
|
||||||
|
// with them.
|
||||||
|
type draftWeek struct {
|
||||||
|
in WeekRequest
|
||||||
|
dates [WeekLength]store.LocalDate
|
||||||
|
pool map[string]Candidate
|
||||||
|
zone *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ harness.Skill[WeekRequest, Week] = draftWeek{}
|
||||||
|
|
||||||
|
// newDraftWeek checks in and returns the skill for it.
|
||||||
|
func newDraftWeek(in WeekRequest) (draftWeek, error) {
|
||||||
|
pool := map[string]Candidate{}
|
||||||
|
for i, c := range in.Pool {
|
||||||
|
if err := c.check(); err != nil {
|
||||||
|
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, dup := pool[c.ID]; dup {
|
||||||
|
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID)
|
||||||
|
}
|
||||||
|
pool[c.ID] = c
|
||||||
|
}
|
||||||
|
if len(pool) < WeekLength {
|
||||||
|
return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)}
|
||||||
|
}
|
||||||
|
dates, err := hh.WeekDays(in.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
|
||||||
|
}
|
||||||
|
zone, err := hh.LoadZone(in.Context.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
|
||||||
|
}
|
||||||
|
// The dinner time is checked here so a bad one fails before the LLM
|
||||||
|
// call rather than in PostProcess.
|
||||||
|
if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil {
|
||||||
|
return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err)
|
||||||
|
}
|
||||||
|
return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// check reports a candidate the pool cannot hold.
|
||||||
|
func (c Candidate) check() error {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"):
|
||||||
|
return fmt.Errorf("id %q is empty or has a line break", c.ID)
|
||||||
|
case strings.TrimSpace(c.Name) == "":
|
||||||
|
return fmt.Errorf("%s has an empty name", c.ID)
|
||||||
|
case c.Kind != KindRegular && c.Kind != KindQuestLog:
|
||||||
|
return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog)
|
||||||
|
case c.Method != "" && !slices.Contains(methods, c.Method):
|
||||||
|
return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", "))
|
||||||
|
case c.Minutes < 0:
|
||||||
|
return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (draftWeek) Name() string { return "draft_week" }
|
||||||
|
|
||||||
|
func (draftWeek) Version() int { return 1 }
|
||||||
|
|
||||||
|
func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||||
|
|
||||||
|
// weekPrompt is the template data of draft_week.v1.tmpl.
|
||||||
|
type weekPrompt struct {
|
||||||
|
// Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12".
|
||||||
|
Days []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) {
|
||||||
|
var days []string
|
||||||
|
for _, d := range s.dates {
|
||||||
|
y, m, day := d.Date()
|
||||||
|
days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String())
|
||||||
|
}
|
||||||
|
var pool []string
|
||||||
|
for _, c := range in.Pool {
|
||||||
|
pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID)))
|
||||||
|
}
|
||||||
|
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool})
|
||||||
|
return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// line renders c as `id "ID": Name (Regular, method, N min, planned last
|
||||||
|
// week)`, leaving out what is unknown.
|
||||||
|
func (c Candidate) line(lastWeek bool) string {
|
||||||
|
attrs := []string{"Regular"}
|
||||||
|
if c.Kind == KindQuestLog {
|
||||||
|
attrs = []string{"Quest log"}
|
||||||
|
}
|
||||||
|
if c.Method != "" {
|
||||||
|
attrs = append(attrs, c.Method)
|
||||||
|
}
|
||||||
|
if c.Minutes > 0 {
|
||||||
|
attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes))
|
||||||
|
}
|
||||||
|
if lastWeek {
|
||||||
|
attrs = append(attrs, "planned last week")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (draftWeek) Schema() llm.JSONSchema {
|
||||||
|
return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"days": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": {"type": "string"},
|
||||||
|
"dinner_id": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["date", "dinner_id"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["days", "note"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}`)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a day, requiring each of its fields as the schema
|
||||||
|
// does.
|
||||||
|
func (d *WeekDay) UnmarshalJSON(data []byte) error {
|
||||||
|
if err := requireFields(data, nil, "date", "dinner_id"); err != nil {
|
||||||
|
return fmt.Errorf("day %s", err)
|
||||||
|
}
|
||||||
|
type plain WeekDay
|
||||||
|
return json.Unmarshal(data, (*plain)(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes the output, requiring each of its fields as the
|
||||||
|
// schema does.
|
||||||
|
func (w *Week) UnmarshalJSON(data []byte) error {
|
||||||
|
if err := requireFields(data, nil, "days", "note"); err != nil {
|
||||||
|
return fmt.Errorf("output %s", err)
|
||||||
|
}
|
||||||
|
type plain Week
|
||||||
|
return json.Unmarshal(data, (*plain)(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate finds every broken rule: a week that is not exactly the seven
|
||||||
|
// dates Monday to Sunday in order, a dinner not in the pool or planned
|
||||||
|
// twice, or a note over NoteLimit characters.
|
||||||
|
func (s draftWeek) Validate(out Week) []string {
|
||||||
|
var problems []string
|
||||||
|
if n := len(out.Days); n != WeekLength {
|
||||||
|
problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s",
|
||||||
|
n, WeekLength, s.dates[0], s.dates[WeekLength-1]))
|
||||||
|
}
|
||||||
|
first := map[string]int{} // dinner ID -> first day planning it
|
||||||
|
for i, d := range out.Days {
|
||||||
|
if i < WeekLength && d.Date != s.dates[i].String() {
|
||||||
|
problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order",
|
||||||
|
i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1]))
|
||||||
|
}
|
||||||
|
c, ok := s.pool[d.DinnerID]
|
||||||
|
if !ok {
|
||||||
|
problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if j, dup := first[d.DinnerID]; dup {
|
||||||
|
problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once",
|
||||||
|
i+1, d.DinnerID, oneLine(c.Name), j+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first[d.DinnerID] = i
|
||||||
|
}
|
||||||
|
if n := utf8.RuneCountInString(out.Note); n > NoteLimit {
|
||||||
|
problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit))
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostProcess tidies the note, marks each Quest log dinner new, computes
|
||||||
|
// each crock-pot dinner's StartBy, and warns when the week repeats more of
|
||||||
|
// last week's dinners than the pool made necessary.
|
||||||
|
func (s draftWeek) PostProcess(out Week) (Week, error) {
|
||||||
|
result := Week{Note: oneLine(out.Note)}
|
||||||
|
var repeats []string
|
||||||
|
for i, d := range out.Days {
|
||||||
|
c := s.pool[d.DinnerID]
|
||||||
|
day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog}
|
||||||
|
if c.Method == "crockpot" && c.Minutes > 0 {
|
||||||
|
dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime)
|
||||||
|
if err != nil {
|
||||||
|
return Week{}, err
|
||||||
|
}
|
||||||
|
day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute)
|
||||||
|
}
|
||||||
|
if slices.Contains(s.in.LastWeek, d.DinnerID) {
|
||||||
|
repeats = append(repeats, oneLine(c.Name))
|
||||||
|
}
|
||||||
|
result.Days = append(result.Days, day)
|
||||||
|
}
|
||||||
|
fresh := 0
|
||||||
|
for id := range s.pool {
|
||||||
|
if !slices.Contains(s.in.LastWeek, id) {
|
||||||
|
fresh++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed := max(0, WeekLength-fresh); len(repeats) > allowed {
|
||||||
|
result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d",
|
||||||
|
len(repeats), strings.Join(repeats, ", "), allowed))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two
|
||||||
|
// of them planned last week.
|
||||||
|
var weekOf = WeekRequest{
|
||||||
|
Context: harness.HouseholdContext{
|
||||||
|
Today: date(2026, 10, 8),
|
||||||
|
Timezone: "America/New_York",
|
||||||
|
Regulars: []harness.RegularDinner{
|
||||||
|
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
|
||||||
|
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
|
||||||
|
},
|
||||||
|
QuestLog: []string{"Pad Thai"},
|
||||||
|
Passed: []string{},
|
||||||
|
Plans: []harness.PlannedDinner{
|
||||||
|
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
|
||||||
|
{Date: date(2026, 10, 6), Name: "Fish Tacos"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WeekStart: date(2026, 10, 12),
|
||||||
|
Pool: []Candidate{
|
||||||
|
{ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240},
|
||||||
|
{ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30},
|
||||||
|
{ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45},
|
||||||
|
{ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"},
|
||||||
|
{ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25},
|
||||||
|
{ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35},
|
||||||
|
{ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"},
|
||||||
|
{ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40},
|
||||||
|
},
|
||||||
|
LastWeek: []string{"fajitas", "tacos", "gone"},
|
||||||
|
DinnerTime: "17:30",
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekDates are the dates of weekOf, Monday to Sunday.
|
||||||
|
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||||
|
|
||||||
|
// week renders a reply planning ids on dates, in order.
|
||||||
|
func week(note string, dates []string, ids ...string) string {
|
||||||
|
var days []string
|
||||||
|
for i, id := range ids {
|
||||||
|
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean plans no dinner of last week.
|
||||||
|
var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
||||||
|
|
||||||
|
// wantWeek is the output for a reply planning ids on weekDates with note.
|
||||||
|
func wantWeek(note string, ids ...string) Week {
|
||||||
|
newYork, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
out := Week{Note: note}
|
||||||
|
for i, id := range ids {
|
||||||
|
day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"}
|
||||||
|
if id == "chili" {
|
||||||
|
y, m, d := date(2026, 10, 12+i).Date()
|
||||||
|
day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork)
|
||||||
|
}
|
||||||
|
out.Days = append(out.Days, day)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// draft runs DraftWeek on in with f and checks f was called exactly calls
|
||||||
|
// times, each at the household temperature with the draft_week schema.
|
||||||
|
func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) {
|
||||||
|
t.Helper()
|
||||||
|
out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in)
|
||||||
|
reqs := f.Requests()
|
||||||
|
if len(reqs) != calls {
|
||||||
|
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||||
|
}
|
||||||
|
if g, _, _ := f.Remaining(); g != 0 {
|
||||||
|
t.Fatalf("%d scripted Generate results unused", g)
|
||||||
|
}
|
||||||
|
for i, req := range reqs {
|
||||||
|
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||||
|
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||||
|
}
|
||||||
|
if req.Schema == nil || req.Schema.Name != "draft_week" {
|
||||||
|
t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, reqs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWeek(t *testing.T, got, want Week) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("output = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekValid(t *testing.T) {
|
||||||
|
var f llm.Fake
|
||||||
|
f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...)))
|
||||||
|
out, reqs, err := draft(t, &f, weekOf, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...))
|
||||||
|
if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||||
|
golden(t, "draft_week_valid.system.golden", system)
|
||||||
|
golden(t, "draft_week_valid.user.golden", user)
|
||||||
|
for _, part := range []string{
|
||||||
|
"## Household context",
|
||||||
|
"### Candidate pool",
|
||||||
|
`- id "chili": Beef Chili (Regular, crockpot, 240 min)`,
|
||||||
|
`- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`,
|
||||||
|
`- id "salmon": Sheet Pan Salmon (Regular, oven)`,
|
||||||
|
`- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`,
|
||||||
|
`- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`,
|
||||||
|
"## Output contract",
|
||||||
|
`"dinner_id"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(system, part) {
|
||||||
|
t.Errorf("system prompt lacks %q", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") {
|
||||||
|
t.Error("the output contract is not after the candidate pool")
|
||||||
|
}
|
||||||
|
want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18."
|
||||||
|
if user != want {
|
||||||
|
t.Errorf("user message = %q, want %q", user, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repaired checks a first reply is repaired once, quoting it and the
|
||||||
|
// problems wanted.
|
||||||
|
func repaired(t *testing.T, first string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
var f llm.Fake
|
||||||
|
f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...)))
|
||||||
|
out, reqs, err := draft(t, &f, weekOf, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertWeek(t, out, wantWeek("Fixed.", clean...))
|
||||||
|
repair := reqs[1].Messages
|
||||||
|
if len(repair) != 3 || repair[1].Content != first {
|
||||||
|
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !strings.Contains(repair[2].Content, w) {
|
||||||
|
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekUnknownIDRepaired(t *testing.T) {
|
||||||
|
ids := slices.Clone(clean)
|
||||||
|
ids[2] = "lasagna"
|
||||||
|
repaired(t, week("A week.", weekDates, ids...),
|
||||||
|
`day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekDuplicateRepaired(t *testing.T) {
|
||||||
|
ids := slices.Clone(clean)
|
||||||
|
ids[5] = "chili"
|
||||||
|
repaired(t, week("A week.", weekDates, ids...),
|
||||||
|
`day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekWrongDatesTwiceFails(t *testing.T) {
|
||||||
|
sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...)
|
||||||
|
var f llm.Fake
|
||||||
|
f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...)))
|
||||||
|
out, _, err := draft(t, &f, weekOf, 2)
|
||||||
|
var bad *harness.BadOutputError
|
||||||
|
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||||
|
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) {
|
||||||
|
t.Fatalf("repair problems = %q", bad.Repair)
|
||||||
|
}
|
||||||
|
if out.Days != nil {
|
||||||
|
t.Fatalf("output = %+v, want none", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekNotEnoughDinners(t *testing.T) {
|
||||||
|
small := weekOf
|
||||||
|
small.Pool = slices.Clone(weekOf.Pool[:6])
|
||||||
|
var f llm.Fake
|
||||||
|
_, _, err := draft(t, &f, small, 0)
|
||||||
|
var short *NotEnoughDinnersError
|
||||||
|
if !errors.As(err, &short) || short.Pool != 6 {
|
||||||
|
t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err)
|
||||||
|
}
|
||||||
|
if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" {
|
||||||
|
t.Fatalf("err = %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekLastWeekRepeats(t *testing.T) {
|
||||||
|
// The pool has six dinners not planned last week, so the week needs one
|
||||||
|
// of last week's two and may plan it without a warning.
|
||||||
|
one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
||||||
|
both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
ids []string
|
||||||
|
warn []string
|
||||||
|
}{
|
||||||
|
"needed repeat": {one, nil},
|
||||||
|
"avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var f llm.Fake
|
||||||
|
f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...)))
|
||||||
|
out, _, err := draft(t, &f, weekOf, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := wantWeek("A week.", tc.ids...)
|
||||||
|
want.Warnings = tc.warn
|
||||||
|
assertWeek(t, out, want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekValidate(t *testing.T) {
|
||||||
|
s, err := newDraftWeek(weekOf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reply := func(note string, ids ...string) Week {
|
||||||
|
var w Week
|
||||||
|
if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 {
|
||||||
|
t.Fatalf("valid output has problems: %q", p)
|
||||||
|
}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
out Week
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
"empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}},
|
||||||
|
"six days": {reply("", clean[:6]...), []string{
|
||||||
|
"days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18",
|
||||||
|
}},
|
||||||
|
"eight days": {
|
||||||
|
Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})},
|
||||||
|
[]string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"},
|
||||||
|
},
|
||||||
|
"long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}},
|
||||||
|
"dates out of order": {
|
||||||
|
Week{Days: []WeekDay{
|
||||||
|
{weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}},
|
||||||
|
{weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}},
|
||||||
|
{weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}},
|
||||||
|
{weekDates[6], "fajitas", false, time.Time{}},
|
||||||
|
}},
|
||||||
|
[]string{
|
||||||
|
`day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`,
|
||||||
|
`day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
|
||||||
|
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftWeekRequestRejected(t *testing.T) {
|
||||||
|
with := func(change func(*WeekRequest)) WeekRequest {
|
||||||
|
in := weekOf
|
||||||
|
in.Pool = slices.Clone(weekOf.Pool)
|
||||||
|
change(&in)
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
in WeekRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }),
|
||||||
|
"draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"},
|
||||||
|
"bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }),
|
||||||
|
`draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`},
|
||||||
|
"bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }),
|
||||||
|
`draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`},
|
||||||
|
"empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }),
|
||||||
|
`draft_week: pool dinner 3: id " " is empty or has a line break`},
|
||||||
|
"id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }),
|
||||||
|
`draft_week: pool dinner 4: id "chili" is in the pool twice`},
|
||||||
|
"bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }),
|
||||||
|
`draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var f llm.Fake
|
||||||
|
if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want {
|
||||||
|
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||||
|
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}}
|
||||||
|
{{define "instructions" -}}
|
||||||
|
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
|
||||||
|
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
|
||||||
|
- Plan each dinner at most once in the week.
|
||||||
|
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
|
||||||
|
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
|
||||||
|
|
||||||
|
Give:
|
||||||
|
- days: the 7 days, each with date and dinner_id.
|
||||||
|
- note: one sentence of at most 160 characters about the week.
|
||||||
|
|
||||||
|
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
|
||||||
|
{"date": "2026-01-05", "dinner_id": "d1"}
|
||||||
|
{{- end}}
|
||||||
|
{{define "user" -}}
|
||||||
|
Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}.
|
||||||
|
{{- end}}
|
||||||
@@ -30,8 +30,9 @@ type SideQuestsRequest struct {
|
|||||||
Needed int
|
Needed int
|
||||||
// Pending holds the names of the Side quests not yet tried or passed.
|
// Pending holds the names of the Side quests not yet tried or passed.
|
||||||
Pending []string
|
Pending []string
|
||||||
// Declined holds the name of every passed Side quest, not only the
|
// Declined holds every passed name — each passed Side quest and each
|
||||||
// recent ones Context holds.
|
// dinner a Rotation check dropped — not only the recent ones Context
|
||||||
|
// holds.
|
||||||
Declined []string
|
Declined []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
## Instructions
|
||||||
|
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
|
||||||
|
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
|
||||||
|
- Plan each dinner at most once in the week.
|
||||||
|
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
|
||||||
|
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
|
||||||
|
|
||||||
|
Give:
|
||||||
|
- days: the 7 days, each with date and dinner_id.
|
||||||
|
- note: one sentence of at most 160 characters about the week.
|
||||||
|
|
||||||
|
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
|
||||||
|
{"date": "2026-01-05", "dinner_id": "d1"}
|
||||||
|
|
||||||
|
## Household context
|
||||||
|
### Today
|
||||||
|
- Thursday 2026-10-08 (America/New_York)
|
||||||
|
|
||||||
|
### Regulars
|
||||||
|
- Beef Chili (Tex-Mex, crockpot, 240 min)
|
||||||
|
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
|
||||||
|
|
||||||
|
### Quest log
|
||||||
|
- Pad Thai
|
||||||
|
|
||||||
|
### Passed, oldest first
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Recent plans
|
||||||
|
- 2026-10-05 Mon: Chicken Fajitas
|
||||||
|
- 2026-10-06 Tue: Fish Tacos
|
||||||
|
|
||||||
|
### Candidate pool
|
||||||
|
- id "chili": Beef Chili (Regular, crockpot, 240 min)
|
||||||
|
- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)
|
||||||
|
- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min)
|
||||||
|
- id "salmon": Sheet Pan Salmon (Regular, oven)
|
||||||
|
- id "tacos": Fish Tacos (Regular, 25 min, planned last week)
|
||||||
|
- id "padthai": Pad Thai (Quest log, stovetop, 35 min)
|
||||||
|
- id "roast": Pot Roast (Regular, crockpot)
|
||||||
|
- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min)
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences.
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"days": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dinner_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"date",
|
||||||
|
"dinner_id"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"days",
|
||||||
|
"note"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18.
|
||||||
@@ -52,20 +52,26 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
|
const listRecentPassedNames = `-- name: ListRecentPassedNames :many
|
||||||
|
|
||||||
SELECT name
|
SELECT name FROM (
|
||||||
FROM suggestions
|
SELECT name, decided_at, id
|
||||||
WHERE status = 'declined'
|
FROM suggestions
|
||||||
|
WHERE status = 'declined'
|
||||||
|
UNION ALL
|
||||||
|
SELECT name, decided_at, id
|
||||||
|
FROM rotation_reviews
|
||||||
|
WHERE decision = 'drop'
|
||||||
|
)
|
||||||
ORDER BY decided_at DESC, id DESC
|
ORDER BY decided_at DESC, id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
|
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The names the most recently declined suggestions were suggested under,
|
// The most recent names the household turned down, newest first, as
|
||||||
// newest first; renaming their dinners does not change them.
|
// ListPassedNames has them.
|
||||||
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
|
func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
|
rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ type RotationReview struct {
|
|||||||
PlanDate string
|
PlanDate string
|
||||||
Status string
|
Status string
|
||||||
Decision sql.NullString
|
Decision sql.NullString
|
||||||
|
Name sql.NullString
|
||||||
DecidedAt sql.NullString
|
DecidedAt sql.NullString
|
||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
@@ -316,4 +317,5 @@ type WeekPlan struct {
|
|||||||
CalendarSyncedAt sql.NullString
|
CalendarSyncedAt sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
Revision int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: plans.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createWeekPlan = `-- name: CreateWeekPlan :exec
|
||||||
|
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'draft', ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateWeekPlanParams struct {
|
||||||
|
ID string
|
||||||
|
WeekStart string
|
||||||
|
AiNote sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createWeekPlan,
|
||||||
|
arg.ID,
|
||||||
|
arg.WeekStart,
|
||||||
|
arg.AiNote,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActiveDraftJob = `-- name: GetActiveDraftJob :one
|
||||||
|
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
|
||||||
|
FROM jobs
|
||||||
|
WHERE kind = ?1
|
||||||
|
AND status IN ('queued', 'running')
|
||||||
|
AND json_extract(payload, '$.week_start') = ?2
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetActiveDraftJobParams struct {
|
||||||
|
Kind string
|
||||||
|
WeekStart string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetActiveDraftJobRow struct {
|
||||||
|
ID string
|
||||||
|
Confirmed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The job of kind queued or running for the week starting week_start,
|
||||||
|
// with whether its payload has confirm. StartDraft enqueues a draft only
|
||||||
|
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||||
|
// there is none.
|
||||||
|
func (q *Queries) GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getActiveDraftJob, arg.Kind, arg.WeekStart)
|
||||||
|
var i GetActiveDraftJobRow
|
||||||
|
err := row.Scan(&i.ID, &i.Confirmed)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWeekPlan = `-- name: GetWeekPlan :one
|
||||||
|
SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at, revision FROM week_plans
|
||||||
|
WHERE week_start = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||||
|
// there is none.
|
||||||
|
func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart)
|
||||||
|
var i WeekPlan
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WeekStart,
|
||||||
|
&i.Status,
|
||||||
|
&i.AiNote,
|
||||||
|
&i.AiRunID,
|
||||||
|
&i.CalendarSyncedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.Revision,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDraftPool = `-- name: ListDraftPool :many
|
||||||
|
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||||
|
WHERE status = 'regular'
|
||||||
|
OR (status = 'quest_log' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE plan_days.dinner_id = dinners.id
|
||||||
|
AND week_plans.week_start >= ?1
|
||||||
|
AND week_plans.week_start <> ?2
|
||||||
|
))
|
||||||
|
ORDER BY normalized_name, id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDraftPoolParams struct {
|
||||||
|
FromWeekStart string
|
||||||
|
WeekStart string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dinners a draft of the week starting week_start may plan: every
|
||||||
|
// Regular, and every Quest log dinner no other plan for the week starting
|
||||||
|
// from_week_start or later has on any day.
|
||||||
|
func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Dinner
|
||||||
|
for rows.Next() {
|
||||||
|
var i Dinner
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.NormalizedName,
|
||||||
|
&i.Status,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Method,
|
||||||
|
&i.MinutesTotal,
|
||||||
|
&i.Difficulty,
|
||||||
|
&i.Cuisine,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.RecipeID,
|
||||||
|
&i.PhotoID,
|
||||||
|
&i.GroundingStatus,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPlanDays = `-- name: ListPlanDays :many
|
||||||
|
SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||||
|
FROM plan_days
|
||||||
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
|
WHERE plan_days.plan_id = ?
|
||||||
|
ORDER BY plan_days.date
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPlanDaysRow struct {
|
||||||
|
Date string
|
||||||
|
IsNew int64
|
||||||
|
Dinner Dinner
|
||||||
|
}
|
||||||
|
|
||||||
|
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||||
|
// no dinner is left out.
|
||||||
|
func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listPlanDays, planID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPlanDaysRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPlanDaysRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Date,
|
||||||
|
&i.IsNew,
|
||||||
|
&i.Dinner.ID,
|
||||||
|
&i.Dinner.Name,
|
||||||
|
&i.Dinner.NormalizedName,
|
||||||
|
&i.Dinner.Status,
|
||||||
|
&i.Dinner.Origin,
|
||||||
|
&i.Dinner.Method,
|
||||||
|
&i.Dinner.MinutesTotal,
|
||||||
|
&i.Dinner.Difficulty,
|
||||||
|
&i.Dinner.Cuisine,
|
||||||
|
&i.Dinner.IsFavorite,
|
||||||
|
&i.Dinner.RecipeID,
|
||||||
|
&i.Dinner.PhotoID,
|
||||||
|
&i.Dinner.GroundingStatus,
|
||||||
|
&i.Dinner.UpdatedBy,
|
||||||
|
&i.Dinner.CreatedAt,
|
||||||
|
&i.Dinner.UpdatedAt,
|
||||||
|
&i.Dinner.RecipeUrl,
|
||||||
|
&i.Dinner.GroundingError,
|
||||||
|
&i.Dinner.GroundingRevision,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many
|
||||||
|
SELECT plan_days.dinner_id
|
||||||
|
FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
|
||||||
|
ORDER BY plan_days.date
|
||||||
|
`
|
||||||
|
|
||||||
|
// The ids of the dinners planned in the week starting week_start, Monday
|
||||||
|
// first.
|
||||||
|
func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []sql.NullString
|
||||||
|
for rows.Next() {
|
||||||
|
var dinner_id sql.NullString
|
||||||
|
if err := rows.Scan(&dinner_id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, dinner_id)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||||
|
revision = revision + 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkWeekPlanEditedParams struct {
|
||||||
|
UpdatedAt string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
|
// changed, any other keeps its status.
|
||||||
|
func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const putPlanDay = `-- name: PutPlanDay :exec
|
||||||
|
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (plan_id, date) DO UPDATE
|
||||||
|
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type PutPlanDayParams struct {
|
||||||
|
ID string
|
||||||
|
PlanID string
|
||||||
|
Date string
|
||||||
|
DinnerID sql.NullString
|
||||||
|
IsNew int64
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||||
|
// when the day exists.
|
||||||
|
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, putPlanDay,
|
||||||
|
arg.ID,
|
||||||
|
arg.PlanID,
|
||||||
|
arg.Date,
|
||||||
|
arg.DinnerID,
|
||||||
|
arg.IsNew,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const redraftWeekPlan = `-- name: RedraftWeekPlan :exec
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type RedraftWeekPlanParams struct {
|
||||||
|
Status string
|
||||||
|
AiNote sql.NullString
|
||||||
|
UpdatedAt string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
|
// revision.
|
||||||
|
func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, redraftWeekPlan,
|
||||||
|
arg.Status,
|
||||||
|
arg.AiNote,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapPlanDay = `-- name: SwapPlanDay :execrows
|
||||||
|
UPDATE plan_days
|
||||||
|
SET dinner_id = ?, is_new = ?, updated_at = ?
|
||||||
|
WHERE plan_id = ? AND date = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SwapPlanDayParams struct {
|
||||||
|
DinnerID sql.NullString
|
||||||
|
IsNew int64
|
||||||
|
UpdatedAt string
|
||||||
|
PlanID string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||||
|
// have is left as it is (0 rows).
|
||||||
|
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, swapPlanDay,
|
||||||
|
arg.DinnerID,
|
||||||
|
arg.IsNew,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.PlanID,
|
||||||
|
arg.Date,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
+63
-6
@@ -25,6 +25,8 @@ type Querier interface {
|
|||||||
CountDeck(ctx context.Context) (int64, error)
|
CountDeck(ctx context.Context) (int64, error)
|
||||||
// How many Regulars the household has; archived dinners are not counted.
|
// How many Regulars the household has; archived dinners are not counted.
|
||||||
CountRegularDinners(ctx context.Context) (int64, error)
|
CountRegularDinners(ctx context.Context) (int64, error)
|
||||||
|
// How many Rotation checks dinner_id has had, pending or decided.
|
||||||
|
CountRotationReviews(ctx context.Context, dinnerID string) (int64, error)
|
||||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||||
@@ -34,10 +36,16 @@ type Querier interface {
|
|||||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
|
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
|
||||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||||
// later renamed.
|
// later renamed.
|
||||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||||
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
||||||
|
CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error
|
||||||
|
// Records the household's decision on a pending Rotation check, with the
|
||||||
|
// dinner's name for a drop and NULL otherwise; one already decided is left
|
||||||
|
// as it is (0 rows).
|
||||||
|
DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error)
|
||||||
// Records the household's decision on a pending suggestion; one already
|
// Records the household's decision on a pending suggestion; one already
|
||||||
// decided is left as it is (0 rows).
|
// decided is left as it is (0 rows).
|
||||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||||
@@ -67,6 +75,11 @@ type Querier interface {
|
|||||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||||
|
// The job of kind queued or running for the week starting week_start,
|
||||||
|
// with whether its payload has confirm. StartDraft enqueues a draft only
|
||||||
|
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||||
|
// there is none.
|
||||||
|
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
|
||||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||||
// when there is none.
|
// when there is none.
|
||||||
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||||
@@ -81,6 +94,10 @@ type Querier interface {
|
|||||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||||
// is none.
|
// is none.
|
||||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||||
|
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||||
|
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||||
|
// most one. Returns sql.ErrNoRows when there is none.
|
||||||
|
GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error)
|
||||||
GetPhoto(ctx context.Context, id string) (Photo, error)
|
GetPhoto(ctx context.Context, id string) (Photo, error)
|
||||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||||
// unique, so the earliest-created (lowest id) match is returned.
|
// unique, so the earliest-created (lowest id) match is returned.
|
||||||
@@ -93,6 +110,9 @@ type Querier interface {
|
|||||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
||||||
// The id of the job that runs batch batch_id.
|
// The id of the job that runs batch batch_id.
|
||||||
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
||||||
|
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||||
|
// there is none.
|
||||||
|
GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error)
|
||||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||||
// link. It writes only an unarchived dinner still as it was observed
|
// link. It writes only an unarchived dinner still as it was observed
|
||||||
@@ -112,37 +132,74 @@ type Querier interface {
|
|||||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||||
// with their dinners.
|
// with their dinners.
|
||||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||||
// The name every declined suggestion was suggested under, oldest decision
|
|
||||||
// first; renaming its dinner does not change it.
|
|
||||||
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
|
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
|
// The dinners a draft of the week starting week_start may plan: every
|
||||||
|
// Regular, and every Quest log dinner no other plan for the week starting
|
||||||
|
// from_week_start or later has on any day.
|
||||||
|
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
|
||||||
|
// Every name the household turned down, oldest decision first: the name
|
||||||
|
// each declined suggestion was suggested under, which renaming its dinner
|
||||||
|
// does not change, and the name each dinner a Rotation check dropped
|
||||||
|
// ("Not for us") had when dropped, which renaming it does not change.
|
||||||
|
ListPassedNames(ctx context.Context) ([]string, error)
|
||||||
|
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||||
|
// date first, with their dinners.
|
||||||
|
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
|
||||||
|
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||||
|
// no dinner is left out.
|
||||||
|
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
|
||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The names the most recently declined suggestions were suggested under,
|
// The most recent names the household turned down, newest first, as
|
||||||
// newest first; renaming their dinners does not change them.
|
// ListPassedNames has them.
|
||||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
|
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||||
|
// of any status, that have no Rotation check for date yet.
|
||||||
|
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||||
// Every dinner a new or renamed dinner must not repeat, with the
|
// Every dinner a new or renamed dinner must not repeat, with the
|
||||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||||
// The Quest log: dinners with status quest_log that no plan for the week
|
// The Quest log: dinners with status quest_log that no plan for the week
|
||||||
// starting from_week_start or later has on any day.
|
// starting from_week_start or later has on any day.
|
||||||
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
|
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
|
||||||
|
// The ids of the dinners planned in the week starting week_start, Monday
|
||||||
|
// first.
|
||||||
|
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
|
||||||
|
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
|
// status is left as it is (0 rows).
|
||||||
|
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||||
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
|
// changed, any other keeps its status.
|
||||||
|
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||||
// Moves a dinner still suggested to status (quest_log or archived),
|
// Moves a dinner still suggested to status (quest_log or archived),
|
||||||
// recording updated_by; a dinner no longer suggested is left as it is
|
// recording updated_by; a dinner no longer suggested is left as it is
|
||||||
// (0 rows).
|
// (0 rows).
|
||||||
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
|
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
|
||||||
|
// Moves a dinner still tried to status (regular, archived or quest_log)
|
||||||
|
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||||
|
// A dinner no longer tried is left as it is (0 rows).
|
||||||
|
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
|
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||||
|
// when the day exists.
|
||||||
|
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||||
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
|
// revision.
|
||||||
|
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
// Marks a pending batch running; a batch in any other status is left as
|
// Marks a pending batch running; a batch in any other status is left as
|
||||||
// it is (0 rows).
|
// it is (0 rows).
|
||||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||||
|
// Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||||
|
// have is left as it is (0 rows).
|
||||||
|
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: rotation_reviews.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countRotationReviews = `-- name: CountRotationReviews :one
|
||||||
|
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// How many Rotation checks dinner_id has had, pending or decided.
|
||||||
|
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRotationReview = `-- name: CreateRotationReview :exec
|
||||||
|
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateRotationReviewParams struct {
|
||||||
|
ID string
|
||||||
|
DinnerID string
|
||||||
|
PlanDate string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createRotationReview,
|
||||||
|
arg.ID,
|
||||||
|
arg.DinnerID,
|
||||||
|
arg.PlanDate,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const decideRotationReview = `-- name: DecideRotationReview :execrows
|
||||||
|
UPDATE rotation_reviews
|
||||||
|
SET status = 'decided',
|
||||||
|
decision = ?1,
|
||||||
|
name = ?2,
|
||||||
|
decided_at = ?3,
|
||||||
|
updated_by = ?4,
|
||||||
|
updated_at = ?3
|
||||||
|
WHERE id = ?5 AND status = 'pending'
|
||||||
|
`
|
||||||
|
|
||||||
|
type DecideRotationReviewParams struct {
|
||||||
|
Decision sql.NullString
|
||||||
|
Name sql.NullString
|
||||||
|
Now sql.NullString
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records the household's decision on a pending Rotation check, with the
|
||||||
|
// dinner's name for a drop and NULL otherwise; one already decided is left
|
||||||
|
// as it is (0 rows).
|
||||||
|
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, decideRotationReview,
|
||||||
|
arg.Decision,
|
||||||
|
arg.Name,
|
||||||
|
arg.Now,
|
||||||
|
arg.UpdatedBy,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPendingRotationReview = `-- name: GetPendingRotationReview :one
|
||||||
|
SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
|
||||||
|
WHERE dinner_id = ? AND status = 'pending'
|
||||||
|
`
|
||||||
|
|
||||||
|
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||||
|
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||||
|
// most one. Returns sql.ErrNoRows when there is none.
|
||||||
|
func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID)
|
||||||
|
var i RotationReview
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.DinnerID,
|
||||||
|
&i.PlanDate,
|
||||||
|
&i.Status,
|
||||||
|
&i.Decision,
|
||||||
|
&i.Name,
|
||||||
|
&i.DecidedAt,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many
|
||||||
|
SELECT rotation_reviews.plan_date, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||||
|
FROM rotation_reviews
|
||||||
|
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
|
||||||
|
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
|
||||||
|
ORDER BY rotation_reviews.plan_date, rotation_reviews.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPendingRotationReviewsRow struct {
|
||||||
|
PlanDate string
|
||||||
|
Dinner Dinner
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||||
|
// date first, with their dinners.
|
||||||
|
func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listPendingRotationReviews)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPendingRotationReviewsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPendingRotationReviewsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.PlanDate,
|
||||||
|
&i.Dinner.ID,
|
||||||
|
&i.Dinner.Name,
|
||||||
|
&i.Dinner.NormalizedName,
|
||||||
|
&i.Dinner.Status,
|
||||||
|
&i.Dinner.Origin,
|
||||||
|
&i.Dinner.Method,
|
||||||
|
&i.Dinner.MinutesTotal,
|
||||||
|
&i.Dinner.Difficulty,
|
||||||
|
&i.Dinner.Cuisine,
|
||||||
|
&i.Dinner.IsFavorite,
|
||||||
|
&i.Dinner.RecipeID,
|
||||||
|
&i.Dinner.PhotoID,
|
||||||
|
&i.Dinner.GroundingStatus,
|
||||||
|
&i.Dinner.UpdatedBy,
|
||||||
|
&i.Dinner.CreatedAt,
|
||||||
|
&i.Dinner.UpdatedAt,
|
||||||
|
&i.Dinner.RecipeUrl,
|
||||||
|
&i.Dinner.GroundingError,
|
||||||
|
&i.Dinner.GroundingRevision,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTriedCandidates = `-- name: ListTriedCandidates :many
|
||||||
|
SELECT DISTINCT dinners.id
|
||||||
|
FROM plan_days
|
||||||
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
|
WHERE plan_days.date = ?1
|
||||||
|
AND dinners.status = 'quest_log'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM rotation_reviews
|
||||||
|
WHERE rotation_reviews.dinner_id = dinners.id
|
||||||
|
AND rotation_reviews.plan_date = plan_days.date
|
||||||
|
)
|
||||||
|
ORDER BY dinners.id
|
||||||
|
`
|
||||||
|
|
||||||
|
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||||
|
// of any status, that have no Rotation check for date yet.
|
||||||
|
func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listTriedCandidates, date)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, id)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markDinnerTried = `-- name: MarkDinnerTried :execrows
|
||||||
|
UPDATE dinners
|
||||||
|
SET status = 'tried', updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'quest_log'
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkDinnerTriedParams struct {
|
||||||
|
UpdatedAt string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
|
// status is left as it is (0 rows).
|
||||||
|
func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveTriedDinner = `-- name: MoveTriedDinner :execrows
|
||||||
|
UPDATE dinners
|
||||||
|
SET status = ?1, updated_by = ?2, updated_at = ?3
|
||||||
|
WHERE id = ?4 AND status = 'tried'
|
||||||
|
`
|
||||||
|
|
||||||
|
type MoveTriedDinnerParams struct {
|
||||||
|
Status string
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
UpdatedAt string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves a dinner still tried to status (regular, archived or quest_log)
|
||||||
|
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||||
|
// A dinner no longer tried is left as it is (0 rows).
|
||||||
|
func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, moveTriedDinner,
|
||||||
|
arg.Status,
|
||||||
|
arg.UpdatedBy,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
@@ -348,17 +348,25 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
|
const listPassedNames = `-- name: ListPassedNames :many
|
||||||
SELECT name
|
SELECT name FROM (
|
||||||
FROM suggestions
|
SELECT name, decided_at, id
|
||||||
WHERE status = 'declined'
|
FROM suggestions
|
||||||
|
WHERE status = 'declined'
|
||||||
|
UNION ALL
|
||||||
|
SELECT name, decided_at, id
|
||||||
|
FROM rotation_reviews
|
||||||
|
WHERE decision = 'drop'
|
||||||
|
)
|
||||||
ORDER BY decided_at, id
|
ORDER BY decided_at, id
|
||||||
`
|
`
|
||||||
|
|
||||||
// The name every declined suggestion was suggested under, oldest decision
|
// Every name the household turned down, oldest decision first: the name
|
||||||
// first; renaming its dinner does not change it.
|
// each declined suggestion was suggested under, which renaming its dinner
|
||||||
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
|
// does not change, and the name each dinner a Rotation check dropped
|
||||||
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
|
// ("Not for us") had when dropped, which renaming it does not change.
|
||||||
|
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listPassedNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-5
@@ -88,6 +88,10 @@ var ErrNotFound = errors.New("jobs: job not found")
|
|||||||
// registered handler.
|
// registered handler.
|
||||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||||
|
|
||||||
|
// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly,
|
||||||
|
// whose jobs only its own service enqueues.
|
||||||
|
var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service")
|
||||||
|
|
||||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||||
// not decode into the kind's payload type.
|
// not decode into the kind's payload type.
|
||||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||||
@@ -130,11 +134,13 @@ type Job struct {
|
|||||||
// never quotes an error.
|
// never quotes an error.
|
||||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||||
|
|
||||||
// handler is a registered kind: run runs a job, and check reports whether
|
// handler is a registered kind: run runs a job, check reports whether a
|
||||||
// a payload decodes into the kind's payload type.
|
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||||
|
// Runner.Enqueue refuses the kind.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
run runFunc
|
run runFunc
|
||||||
check func(payload []byte) bool
|
check func(payload []byte) bool
|
||||||
|
serviceOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner owns the job handlers and the worker pool.
|
// Runner owns the job handlers and the worker pool.
|
||||||
@@ -286,15 +292,30 @@ func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string,
|
|||||||
return createJob(ctx, db.New(tx), k.name, data)
|
return createJob(ctx, db.New(tx), k.name, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so
|
||||||
|
// its jobs are enqueued only through k, by the service that checks what
|
||||||
|
// must hold before one starts. It must be called before Start.
|
||||||
|
func (k *Kind[P]) ServiceOnly() error {
|
||||||
|
k.r.mu.Lock()
|
||||||
|
defer k.r.mu.Unlock()
|
||||||
|
if k.r.started {
|
||||||
|
return fmt.Errorf("jobs: service-only %q: runner already started", k.name)
|
||||||
|
}
|
||||||
|
h := k.r.handlers[k.name]
|
||||||
|
h.serviceOnly = true
|
||||||
|
k.r.handlers[k.name] = h
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||||
// call it after committing the transaction a job was enqueued in.
|
// call it after committing the transaction a job was enqueued in.
|
||||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||||
|
|
||||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||||
// ErrUnknownKind for a kind with no registered handler and
|
// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for
|
||||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
// a ServiceOnly kind and ErrInvalidPayload for a payload that does not
|
||||||
// payload type; neither stores a job.
|
// decode into the kind's payload type; none stores a job.
|
||||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
h, ok := r.handlers[kind]
|
h, ok := r.handlers[kind]
|
||||||
@@ -302,6 +323,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", ErrUnknownKind
|
return "", ErrUnknownKind
|
||||||
}
|
}
|
||||||
|
if h.serviceOnly {
|
||||||
|
return "", ErrServiceOnly
|
||||||
|
}
|
||||||
if !h.check(payload) {
|
if !h.check(payload) {
|
||||||
return "", ErrInvalidPayload
|
return "", ErrInvalidPayload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -736,3 +736,39 @@ func TestRunnerEnqueueByKindName(t *testing.T) {
|
|||||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceOnlyKind(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
if err := k.ServiceOnly(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) {
|
||||||
|
t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("refused enqueue stored %d jobs, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := k.Enqueue(ctx, countPayload{Steps: 3})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||||
|
t.Errorf("job enqueued through its Kind = %+v, want succeeded", job)
|
||||||
|
}
|
||||||
|
if err := k.ServiceOnly(); err == nil {
|
||||||
|
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"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/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
|
||||||
|
// the household confirmed drafting over a synced or changed plan.
|
||||||
|
type DraftJob struct {
|
||||||
|
WeekStart string `json:"week_start"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftResult is the KindDraftWeek result: the week drafted and its plan.
|
||||||
|
type draftResult struct {
|
||||||
|
WeekStart string `json:"week_start"`
|
||||||
|
PlanID string `json:"plan_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||||
|
|
||||||
|
// runDraft drafts the week p.WeekStart: draft_week plans it from the
|
||||||
|
// Regulars and the Quest log dinners no other current or later plan has,
|
||||||
|
// given the household context and last week's dinners, and the draft is
|
||||||
|
// stored as the week's plan if the plan is still as it was before
|
||||||
|
// draft_week ran and every dinner it planned is still in the pool. A pool
|
||||||
|
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
|
||||||
|
// failing with ReasonAIFailed; no week is made up instead.
|
||||||
|
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
|
||||||
|
weekStart, err := ParseWeekStart(p.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
|
||||||
|
}
|
||||||
|
base, err := s.revision(ctx, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return draftResult{}, err
|
||||||
|
}
|
||||||
|
in, err := s.request(ctx, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return draftResult{}, err
|
||||||
|
}
|
||||||
|
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return draftResult{}, ctx.Err()
|
||||||
|
}
|
||||||
|
var few *skills.NotEnoughDinnersError
|
||||||
|
if errors.As(err, &few) {
|
||||||
|
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
|
||||||
|
}
|
||||||
|
// Only the code is logged: the error may quote a model's response.
|
||||||
|
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
|
||||||
|
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
|
||||||
|
}
|
||||||
|
for _, w := range week.Warnings {
|
||||||
|
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
|
||||||
|
}
|
||||||
|
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
|
||||||
|
if err != nil {
|
||||||
|
return draftResult{}, err
|
||||||
|
}
|
||||||
|
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noPlan is the revision of a week with no plan.
|
||||||
|
const noPlan = -1
|
||||||
|
|
||||||
|
// revision returns the revision of the plan of the week starting
|
||||||
|
// weekStart, or noPlan when the week has none.
|
||||||
|
func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) {
|
||||||
|
cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String())
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return noPlan, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
return cur.Revision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// request reads what draft_week is given for the week starting weekStart:
|
||||||
|
// the household context, the pool, last week's dinners and the dinner
|
||||||
|
// time.
|
||||||
|
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
|
||||||
|
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||||
|
if err != nil {
|
||||||
|
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
q := db.New(s.st.Reader)
|
||||||
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||||
|
}
|
||||||
|
thisWeek := household.WeekStart(hctx.Today)
|
||||||
|
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
|
||||||
|
if err != nil {
|
||||||
|
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
|
||||||
|
}
|
||||||
|
pool := make([]skills.Candidate, len(rows))
|
||||||
|
for i, d := range rows {
|
||||||
|
kind := skills.KindRegular
|
||||||
|
if d.Status == dinners.StatusQuestLog {
|
||||||
|
kind = skills.KindQuestLog
|
||||||
|
}
|
||||||
|
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
|
||||||
|
}
|
||||||
|
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
|
||||||
|
if err != nil {
|
||||||
|
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
|
||||||
|
}
|
||||||
|
lastWeek := []string{}
|
||||||
|
for _, id := range last {
|
||||||
|
lastWeek = append(lastWeek, id.String)
|
||||||
|
}
|
||||||
|
return skills.WeekRequest{
|
||||||
|
Context: hctx,
|
||||||
|
WeekStart: weekStart,
|
||||||
|
Pool: pool,
|
||||||
|
LastWeek: lastWeek,
|
||||||
|
DinnerTime: settings.DinnerTime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeDraft stores week as the plan of the week starting weekStart in one
|
||||||
|
// transaction and returns the plan's id. A new plan is draft. A draft
|
||||||
|
// replaces the days of an existing plan, keeping each day's calendar
|
||||||
|
// event id; a draft plan stays draft and a synced or changed one becomes
|
||||||
|
// changed, since Calendar no longer shows it. A plan synced since the
|
||||||
|
// draft was asked for without confirm is left as it is, failing with
|
||||||
|
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
|
||||||
|
// revision before draft_week ran, or noPlan), failing with
|
||||||
|
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
|
||||||
|
// draft_week was given: a day whose dinner is no longer in it, or is in it
|
||||||
|
// as a Regular rather than the Quest log or the other way round, leaves
|
||||||
|
// everything as it is, failing with ReasonPoolChanged.
|
||||||
|
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
|
||||||
|
var planID string
|
||||||
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: list draft pool: %w", err)
|
||||||
|
}
|
||||||
|
pool := make(map[string]string, len(rows))
|
||||||
|
for _, d := range rows {
|
||||||
|
pool[d.ID] = d.Status
|
||||||
|
}
|
||||||
|
for _, d := range week.Days {
|
||||||
|
status, ok := pool[d.DinnerID]
|
||||||
|
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
|
||||||
|
return &jobs.Failure{Code: ReasonPoolChanged}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
|
||||||
|
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows) && base != noPlan:
|
||||||
|
return &jobs.Failure{Code: ReasonPlanChanged}
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
planID = store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
|
||||||
|
ID: planID,
|
||||||
|
WeekStart: weekStart.String(),
|
||||||
|
AiNote: note,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
case cur.Status != StatusDraft && !confirm:
|
||||||
|
return &jobs.Failure{Code: ReasonConfirmRequired}
|
||||||
|
case cur.Revision != base:
|
||||||
|
return &jobs.Failure{Code: ReasonPlanChanged}
|
||||||
|
default:
|
||||||
|
planID = cur.ID
|
||||||
|
status := StatusDraft
|
||||||
|
if cur.Status != StatusDraft {
|
||||||
|
status = StatusChanged
|
||||||
|
}
|
||||||
|
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
|
||||||
|
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range week.Days {
|
||||||
|
isNew := int64(0)
|
||||||
|
if d.IsNew {
|
||||||
|
isNew = 1
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
|
||||||
|
ID: store.NewID(),
|
||||||
|
PlanID: planID,
|
||||||
|
Date: d.Date,
|
||||||
|
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
|
||||||
|
IsNew: isNew,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return planID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDays returns d plus n days.
|
||||||
|
func addDays(d store.LocalDate, n int) store.LocalDate {
|
||||||
|
y, m, day := d.Date()
|
||||||
|
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
// Package plan keeps the household's weekly plans: the Draft board the AI
|
||||||
|
// drafts from the Regulars and the Quest log, and the swaps the household
|
||||||
|
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
|
||||||
|
// a day from Monday to Sunday, and the job stores them as the week's plan,
|
||||||
|
// replacing any draft before it unless the household edited the plan
|
||||||
|
// while draft_week ran. A plan is draft until Push to Calendar
|
||||||
|
// syncs it (Feature 16); an edit of a synced plan makes it changed.
|
||||||
|
//
|
||||||
|
// The Rotation check follows a plan day: MarkTried moves the Quest log
|
||||||
|
// dinners planned on it to tried, and the household answers "Keep it in
|
||||||
|
// rotation?" for each (D-021). Only their keep makes a dinner a Regular.
|
||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"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/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindDraftWeek is the job that drafts a week's plan.
|
||||||
|
const KindDraftWeek = "draft_week"
|
||||||
|
|
||||||
|
// Plan statuses (the week_plans.status column).
|
||||||
|
const (
|
||||||
|
StatusDraft = "draft"
|
||||||
|
StatusSynced = "synced"
|
||||||
|
StatusChanged = "changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
|
||||||
|
// Any other failure, such as the database's, is the runner's
|
||||||
|
// internal_error.
|
||||||
|
const (
|
||||||
|
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
|
||||||
|
// seven dinners to draft from.
|
||||||
|
ReasonNotEnoughDinners = "not_enough_dinners"
|
||||||
|
// ReasonAIFailed: draft_week failed; no week is made up instead.
|
||||||
|
ReasonAIFailed = "ai_failed"
|
||||||
|
// ReasonConfirmRequired: the plan was synced after the draft was
|
||||||
|
// asked for without confirm, so the draft does not replace it.
|
||||||
|
ReasonConfirmRequired = "confirm_required"
|
||||||
|
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
|
||||||
|
// draft does not replace the edit.
|
||||||
|
ReasonPlanChanged = "plan_changed"
|
||||||
|
// ReasonPoolChanged: a dinner the draft planned left the pool while
|
||||||
|
// draft_week ran (archived, tried, or its Quest log dinner planned on
|
||||||
|
// another week), so the draft is not stored; drafting again uses the
|
||||||
|
// pool as it is now.
|
||||||
|
ReasonPoolChanged = "pool_changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reasons are every draft failure reason.
|
||||||
|
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
|
||||||
|
|
||||||
|
// ContextBudget is the character budget of the household context
|
||||||
|
// draft_week is given (see harness.BuildContext).
|
||||||
|
const ContextBudget = 16000
|
||||||
|
|
||||||
|
// plannable are the dinner statuses a day may be swapped to.
|
||||||
|
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
|
||||||
|
|
||||||
|
// ErrNotFound is returned for a week with no plan.
|
||||||
|
var ErrNotFound = errors.New("plan not found")
|
||||||
|
|
||||||
|
// ErrDinnerNotFound is a swap to an unknown dinner.
|
||||||
|
var ErrDinnerNotFound = errors.New("dinner not found")
|
||||||
|
|
||||||
|
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
|
||||||
|
// the week.
|
||||||
|
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
|
||||||
|
|
||||||
|
// ErrConfirmRequired is a draft over a synced or changed plan asked for
|
||||||
|
// without confirm.
|
||||||
|
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
|
||||||
|
|
||||||
|
// ErrDraftRunning is a confirmed draft asked for while a draft of the week
|
||||||
|
// asked for without confirm is queued or running.
|
||||||
|
var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes")
|
||||||
|
|
||||||
|
// InvalidWeekError is a week start that is not a Monday date.
|
||||||
|
type InvalidWeekError struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidWeekError) Error() string {
|
||||||
|
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidDayError is a swap of a date outside the plan's week.
|
||||||
|
type InvalidDayError struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidDayError) Error() string {
|
||||||
|
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
|
||||||
|
// nor in the Quest log.
|
||||||
|
type DinnerNotPlannableError struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DinnerNotPlannableError) Error() string {
|
||||||
|
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
|
||||||
|
func ParseWeekStart(s string) (store.LocalDate, error) {
|
||||||
|
d, err := store.ParseLocalDate(s)
|
||||||
|
if err != nil {
|
||||||
|
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||||
|
}
|
||||||
|
if _, err := household.WeekDays(d); err != nil {
|
||||||
|
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drafter runs draft_week on in; scope names the week. In production it is
|
||||||
|
// skills.DraftWeek with the household's harness.Config.
|
||||||
|
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
|
||||||
|
|
||||||
|
// Service drafts, reads and edits weekly plans.
|
||||||
|
type Service struct {
|
||||||
|
st *store.Store
|
||||||
|
draft Drafter
|
||||||
|
logger *slog.Logger
|
||||||
|
draftJob *jobs.Kind[DraftJob]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns a service over st that drafts with draft and
|
||||||
|
// registers the KindDraftWeek job on runner, which must not have started
|
||||||
|
// yet. The kind is service-only: StartDraft, which keeps one draft in
|
||||||
|
// flight per week, is the only way to enqueue it.
|
||||||
|
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||||
|
switch {
|
||||||
|
case st == nil:
|
||||||
|
return nil, errors.New("plan: nil store")
|
||||||
|
case draft == nil:
|
||||||
|
return nil, errors.New("plan: nil drafter")
|
||||||
|
case runner == nil:
|
||||||
|
return nil, errors.New("plan: nil job runner")
|
||||||
|
case logger == nil:
|
||||||
|
return nil, errors.New("plan: nil logger")
|
||||||
|
}
|
||||||
|
s := &Service{st: st, draft: draft, logger: logger}
|
||||||
|
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := kind.ServiceOnly(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.draftJob = kind
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan is a week's plan as the API shows it.
|
||||||
|
type Plan struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WeekStart string `json:"week_start"`
|
||||||
|
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
|
||||||
|
WeekLabel string `json:"week_label"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AINote *string `json:"ai_note"`
|
||||||
|
CalendarSyncedAt *string `json:"calendar_synced_at"`
|
||||||
|
Days []Day `json:"days"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day is one day of a plan.
|
||||||
|
type Day struct {
|
||||||
|
// Date is household-local, YYYY-MM-DD.
|
||||||
|
Date string `json:"date"`
|
||||||
|
Dinner Dinner `json:"dinner"`
|
||||||
|
// IsNew is true for a dinner planned from the Quest log.
|
||||||
|
IsNew bool `json:"is_new"`
|
||||||
|
// StartBy is when a crock-pot dinner must start to be ready at the
|
||||||
|
// household's dinner time; null for any other dinner and for a
|
||||||
|
// crock-pot dinner whose minutes are unknown.
|
||||||
|
StartBy *time.Time `json:"start_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dinner is a planned dinner as a plan day shows it.
|
||||||
|
type Dinner struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Photo *string `json:"photo"`
|
||||||
|
Minutes *int64 `json:"minutes"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the plan of the week starting weekStart, or ErrNotFound.
|
||||||
|
// The plan and the household settings come from one read transaction.
|
||||||
|
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
|
||||||
|
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, tx.Rollback())
|
||||||
|
}()
|
||||||
|
return readPlan(ctx, db.New(tx), weekStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPlan reads the plan of the week starting weekStart through q, with
|
||||||
|
// each day's start_by from the household's dinner time and zone.
|
||||||
|
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
|
||||||
|
label, err := household.WeekLabel(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
row, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Plan{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||||
|
}
|
||||||
|
zone, err := household.LoadZone(settings.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := q.ListPlanDays(ctx, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
dates, err := household.WeekDays(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) != len(dates) {
|
||||||
|
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
|
||||||
|
}
|
||||||
|
p := Plan{
|
||||||
|
ID: row.ID,
|
||||||
|
WeekStart: row.WeekStart,
|
||||||
|
WeekLabel: label,
|
||||||
|
Status: row.Status,
|
||||||
|
AINote: nullString(row.AiNote),
|
||||||
|
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
|
||||||
|
Days: make([]Day, len(rows)),
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}
|
||||||
|
for i, r := range rows {
|
||||||
|
if r.Date != dates[i].String() {
|
||||||
|
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
|
||||||
|
}
|
||||||
|
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
|
||||||
|
}
|
||||||
|
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBy returns when crock-pot dinner d must start on date to be ready at
|
||||||
|
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
|
||||||
|
// dinner whose minutes are unknown.
|
||||||
|
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
|
||||||
|
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
at, err := household.LocalTime(zone, date, dinnerTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
|
||||||
|
return &at, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft is a draft asked for: the job drafting the week, and whether this
|
||||||
|
// call enqueued it rather than found one already queued or running.
|
||||||
|
type Draft struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
Created bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDraft enqueues the KindDraftWeek job drafting the week starting
|
||||||
|
// weekStart, unless one is already queued or running for it, which it
|
||||||
|
// returns instead with Created false, so a week has at most one draft in
|
||||||
|
// flight. A confirmed draft joins only a confirmed job: while the week's
|
||||||
|
// job was asked for without confirm, it fails with ErrDraftRunning and
|
||||||
|
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
||||||
|
// else it fails with ErrConfirmRequired and enqueues nothing.
|
||||||
|
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
||||||
|
if _, err := household.WeekDays(weekStart); err != nil {
|
||||||
|
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||||
|
}
|
||||||
|
var d Draft
|
||||||
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
case cur.Status != StatusDraft && !confirm:
|
||||||
|
return ErrConfirmRequired
|
||||||
|
}
|
||||||
|
active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
|
||||||
|
case confirm && active.Confirmed != 1:
|
||||||
|
return ErrDraftRunning
|
||||||
|
default:
|
||||||
|
d = Draft{JobID: active.ID}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
d = Draft{JobID: id, Created: true}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Draft{}, err
|
||||||
|
}
|
||||||
|
if d.Created {
|
||||||
|
s.draftJob.Wake()
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||||
|
// day new when the dinner is in the Quest log. Swapping a synced plan
|
||||||
|
// makes it changed. The week must have a plan (else ErrNotFound), date
|
||||||
|
// must be one of its days (else *InvalidDayError), the dinner must be a
|
||||||
|
// Regular or in the Quest log (else ErrDinnerNotFound or
|
||||||
|
// *DinnerNotPlannableError), and it must not be on another day of the
|
||||||
|
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
|
||||||
|
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
|
||||||
|
dates, err := household.WeekDays(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||||
|
}
|
||||||
|
day, err := store.ParseLocalDate(date)
|
||||||
|
if err != nil || !slices.Contains(dates[:], day) {
|
||||||
|
return Plan{}, &InvalidDayError{Value: date}
|
||||||
|
}
|
||||||
|
var p Plan
|
||||||
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
d, err := q.GetDinner(ctx, dinnerID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrDinnerNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(plannable, d.Status) {
|
||||||
|
return &DinnerNotPlannableError{Status: d.Status}
|
||||||
|
}
|
||||||
|
days, err := q.ListPlanDays(ctx, cur.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
|
||||||
|
return ErrDinnerAlreadyPlanned
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
|
||||||
|
DinnerID: sql.NullString{String: d.ID, Valid: true},
|
||||||
|
IsNew: isNew(d.Status),
|
||||||
|
UpdatedAt: now,
|
||||||
|
PlanID: cur.ID,
|
||||||
|
Date: day.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
|
||||||
|
}
|
||||||
|
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
|
||||||
|
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
|
||||||
|
}
|
||||||
|
p, err = readPlan(ctx, q, weekStart)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||||
|
func isNew(status string) int64 {
|
||||||
|
if status == dinners.StatusQuestLog {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDinner(d db.Dinner) Dinner {
|
||||||
|
out := Dinner{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
Method: nullString(d.Method),
|
||||||
|
Origin: d.Origin,
|
||||||
|
}
|
||||||
|
if d.MinutesTotal.Valid {
|
||||||
|
out.Minutes = &d.MinutesTotal.Int64
|
||||||
|
}
|
||||||
|
if d.PhotoID.Valid {
|
||||||
|
u := dinners.PhotoURL(d.PhotoID.String)
|
||||||
|
out.Photo = &u
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(s sql.NullString) *string {
|
||||||
|
if !s.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s.String
|
||||||
|
}
|
||||||
@@ -0,0 +1,946 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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/db"
|
||||||
|
"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/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// week is the Monday the tests draft, and weekDates its days.
|
||||||
|
var (
|
||||||
|
week = mustDate("2026-10-12")
|
||||||
|
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustDate(s string) store.LocalDate {
|
||||||
|
d, err := store.ParseLocalDate(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
svc *plan.Service
|
||||||
|
st *store.Store
|
||||||
|
runner *jobs.Runner
|
||||||
|
model *llm.Fake
|
||||||
|
// ids are the seeded dinners' ids by name.
|
||||||
|
ids map[string]string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
inputs []skills.WeekRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEnv returns a plan service over a migrated store in zone
|
||||||
|
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
||||||
|
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
|
||||||
|
// with two workers. It drafts through the real harness and draft_week with
|
||||||
|
// the scripted e.model.
|
||||||
|
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, "America/New_York"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
|
||||||
|
for _, d := range []struct {
|
||||||
|
name, status, method string
|
||||||
|
minutes int64
|
||||||
|
}{
|
||||||
|
{"Beef Chili", "regular", "crockpot", 240},
|
||||||
|
{"Pot Roast", "regular", "crockpot", 0},
|
||||||
|
{"Chicken Fajitas", "regular", "stovetop", 30},
|
||||||
|
{"Spaghetti Bolognese", "regular", "stovetop", 45},
|
||||||
|
{"Sheet Pan Salmon", "regular", "oven", 35},
|
||||||
|
{"Pad Thai", "quest_log", "stovetop", 35},
|
||||||
|
{"Shawarma Bowls", "quest_log", "oven", 40},
|
||||||
|
} {
|
||||||
|
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
|
||||||
|
}
|
||||||
|
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := harness.Config{
|
||||||
|
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||||
|
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||||
|
Store: st,
|
||||||
|
Guard: guard,
|
||||||
|
}
|
||||||
|
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.inputs = append(e.inputs, in)
|
||||||
|
e.mu.Unlock()
|
||||||
|
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
// Two workers, as cmd/server runs, so drafts of a week could overlap.
|
||||||
|
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// input returns the request of the ith draft_week run.
|
||||||
|
func (e *env) input(i int) skills.WeekRequest {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.inputs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// start starts the env's job runner, stopping it when the test ends.
|
||||||
|
func (e *env) start(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := e.runner.Start(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
e.runner.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dinner stores a dinner and returns its id; minutes 0 is unknown.
|
||||||
|
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
|
||||||
|
t.Helper()
|
||||||
|
now := store.Now().String()
|
||||||
|
origin := "regular"
|
||||||
|
if status != "regular" {
|
||||||
|
origin = "side_quest"
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
Method: sql.NullString{String: method, Valid: method != ""},
|
||||||
|
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||||
|
GroundingStatus: "manual",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// reply scripts one draft_week reply planning the dinners named names on
|
||||||
|
// weekDates, in order.
|
||||||
|
func (e *env) reply(note string, names ...string) {
|
||||||
|
e.model.QueueGenerate(e.weekReply(note, names...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekReply is the draft_week reply planning the dinners named names on
|
||||||
|
// weekDates, in order.
|
||||||
|
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
|
||||||
|
return e.weekReplyOn(weekDates, note, names...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekReplyOn is the draft_week reply planning the dinners named names on
|
||||||
|
// dates, in order.
|
||||||
|
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
|
||||||
|
var days []string
|
||||||
|
for i, name := range names {
|
||||||
|
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
|
||||||
|
}
|
||||||
|
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
||||||
|
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
|
||||||
|
// on Tuesday.
|
||||||
|
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||||
|
|
||||||
|
// waitJob waits for job id to finish and returns it.
|
||||||
|
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
job, err := e.runner.Get(t.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("job %s still %s", id, job.Status)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// draft asks for a draft of week with confirm and waits for its job.
|
||||||
|
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
|
||||||
|
t.Helper()
|
||||||
|
d, err := e.svc.StartDraft(t.Context(), week, confirm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !d.Created {
|
||||||
|
t.Fatalf("draft job %s was already queued", d.JobID)
|
||||||
|
}
|
||||||
|
return e.waitJob(t, d.JobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustDraft drafts week with confirm, requiring the job to succeed, and
|
||||||
|
// returns the plan.
|
||||||
|
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
|
||||||
|
t.Helper()
|
||||||
|
job := e.draft(t, confirm)
|
||||||
|
if job.Status != jobs.StatusSucceeded {
|
||||||
|
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
WeekStart string `json:"week_start"`
|
||||||
|
PlanID string `json:"plan_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.WeekStart != week.String() || res.PlanID != p.ID {
|
||||||
|
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStatus sets the plan of week to status, as Push to Calendar would.
|
||||||
|
func (e *env) setStatus(t *testing.T, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
|
||||||
|
status, store.Now().String(), week.String()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayNames(p plan.Plan) []string {
|
||||||
|
var names []string
|
||||||
|
for _, d := range p.Days {
|
||||||
|
names = append(names, d.Dinner.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftCreatesPlan(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
|
||||||
|
|
||||||
|
p := e.mustDraft(t, false)
|
||||||
|
|
||||||
|
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
|
||||||
|
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
|
||||||
|
}
|
||||||
|
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
|
||||||
|
t.Errorf("ai_note = %v", p.AINote)
|
||||||
|
}
|
||||||
|
if p.CalendarSyncedAt != nil {
|
||||||
|
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
|
||||||
|
}
|
||||||
|
if got := dayNames(p); !slices.Equal(got, fullWeek) {
|
||||||
|
t.Errorf("days = %v, want %v", got, fullWeek)
|
||||||
|
}
|
||||||
|
for i, d := range p.Days {
|
||||||
|
if d.Date != weekDates[i] {
|
||||||
|
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
|
||||||
|
}
|
||||||
|
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
|
||||||
|
if d.IsNew != wantNew {
|
||||||
|
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
|
||||||
|
}
|
||||||
|
if d.Dinner.ID != e.ids[d.Dinner.Name] {
|
||||||
|
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chili := p.Days[0]
|
||||||
|
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
|
||||||
|
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is every Regular and Quest log dinner; last week has no
|
||||||
|
// plan.
|
||||||
|
in := e.input(0)
|
||||||
|
var pool []string
|
||||||
|
for _, c := range in.Pool {
|
||||||
|
pool = append(pool, c.Name+"/"+c.Kind)
|
||||||
|
}
|
||||||
|
slices.Sort(pool)
|
||||||
|
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
|
||||||
|
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
|
||||||
|
if !slices.Equal(pool, want) {
|
||||||
|
t.Errorf("pool = %v, want %v", pool, want)
|
||||||
|
}
|
||||||
|
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
|
||||||
|
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("", fullWeek...)
|
||||||
|
p := e.mustDraft(t, false)
|
||||||
|
|
||||||
|
newYork, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
|
||||||
|
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||||
|
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
|
||||||
|
// crock-pot dinners.
|
||||||
|
for _, d := range p.Days[1:] {
|
||||||
|
if d.StartBy != nil {
|
||||||
|
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.AINote != nil {
|
||||||
|
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start_by follows the household's dinner time.
|
||||||
|
dinnerTime := "18:45"
|
||||||
|
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err = e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||||
|
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
|
||||||
|
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
// New York leaves daylight saving time at 02:00 on Sunday 2026-11-01.
|
||||||
|
// Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour
|
||||||
|
// crock-pot dinner on Sunday, so its start crosses the change.
|
||||||
|
stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200)
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
id := store.NewID()
|
||||||
|
dst := mustDate("2026-10-26")
|
||||||
|
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"}
|
||||||
|
for i, date := range dates {
|
||||||
|
dinner := e.ids["Beef Chili"]
|
||||||
|
if i == 6 {
|
||||||
|
dinner = stew
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
|
||||||
|
DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and
|
||||||
|
// twenty hours before it is 22:30 EDT on Saturday.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
day int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{5, `"2026-10-31T13:30:00-04:00"`},
|
||||||
|
{6, `"2026-10-31T22:30:00-04:00"`},
|
||||||
|
} {
|
||||||
|
b, err := json.Marshal(p.Days[tc.day].StartBy)
|
||||||
|
if err != nil || string(b) != tc.want {
|
||||||
|
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
|
||||||
|
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
|
||||||
|
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p, err = e.svc.Get(t.Context(), dst); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
|
||||||
|
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftGivesLastWeek(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
last := mustDate("2026-10-05")
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
id := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
|
||||||
|
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.reply("", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
|
||||||
|
t.Errorf("last week = %v, want Chicken Fajitas", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
// Pad Thai is on a later week's plan, so the Quest log no longer
|
||||||
|
// offers it; an eighth dinner keeps the pool at seven.
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
later := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
|
||||||
|
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
|
||||||
|
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||||
|
e.reply("", names...)
|
||||||
|
p := e.mustDraft(t, false)
|
||||||
|
if got := dayNames(p); !slices.Equal(got, names) {
|
||||||
|
t.Errorf("days = %v", got)
|
||||||
|
}
|
||||||
|
for _, c := range e.input(0).Pool {
|
||||||
|
if c.Name == "Pad Thai" {
|
||||||
|
t.Errorf("pool has Pad Thai, planned on a later week")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A redraft of the week keeps offering its own Quest log dinners.
|
||||||
|
e.reply("", names...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
|
||||||
|
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
|
||||||
|
for _, status := range []string{"archived", "tried"} {
|
||||||
|
t.Run(status, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
// Pad Thai leaves the Quest log while draft_week plans it.
|
||||||
|
started, release := make(chan struct{}), make(chan struct{})
|
||||||
|
r := e.weekReply("", fullWeek...)
|
||||||
|
r.Started, r.Release = started, release
|
||||||
|
e.model.QueueGenerate(r)
|
||||||
|
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
job := e.waitJob(t, d.JobID)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||||
|
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
// This week's draft plans Pad Thai; while draft_week runs, the other
|
||||||
|
// worker drafts a later week with Pad Thai too and stores it first.
|
||||||
|
started, release := make(chan struct{}), make(chan struct{})
|
||||||
|
r := e.weekReply("", fullWeek...)
|
||||||
|
r.Started, r.Release = started, release
|
||||||
|
e.model.QueueGenerate(r)
|
||||||
|
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
later := mustDate("2099-01-05")
|
||||||
|
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
|
||||||
|
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
|
||||||
|
other, err := e.svc.StartDraft(t.Context(), later, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
|
||||||
|
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
job := e.waitJob(t, d.JobID)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||||
|
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
job := e.draft(t, false)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
|
||||||
|
}
|
||||||
|
if n := len(e.model.Requests()); n != 0 {
|
||||||
|
t.Errorf("model called %d times", n)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||||
|
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftFailsWhenAIFails(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||||
|
job := e.draft(t, false)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||||
|
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartDraftIsSingleFlight(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
first, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !first.Created || again.Created || again.JobID != first.JobID {
|
||||||
|
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
|
||||||
|
}
|
||||||
|
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !other.Created || other.JobID == first.JobID {
|
||||||
|
t.Errorf("another week's draft = %+v, want its own job", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimultaneousStartDraftsShareOneJob(t *testing.T) {
|
||||||
|
for _, confirmFirst := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
first, err := e.svc.StartDraft(t.Context(), week, confirmFirst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Twenty simultaneous drafts, half of them confirmed: every
|
||||||
|
// one joins the queued job, except a confirmed one while the
|
||||||
|
// job is unconfirmed, which is refused.
|
||||||
|
const n = 20
|
||||||
|
drafts := make([]plan.Draft, n)
|
||||||
|
errs := make([]error, n)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, d := range drafts {
|
||||||
|
if i%2 == 1 && !confirmFirst {
|
||||||
|
if !errors.Is(errs[i], plan.ErrDraftRunning) {
|
||||||
|
t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errs[i] != nil || d.JobID != first.JobID || d.Created {
|
||||||
|
t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 1 {
|
||||||
|
t.Errorf("%d draft jobs, want 1", queued)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("first", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
|
||||||
|
// An unconfirmed redraft is running when the plan is synced; the
|
||||||
|
// household then confirms a redraft. With a second worker idle, a
|
||||||
|
// second job would draft alongside the first, so the confirmed draft
|
||||||
|
// is refused and only the first job runs draft_week.
|
||||||
|
reversed := slices.Clone(fullWeek)
|
||||||
|
slices.Reverse(reversed)
|
||||||
|
started, release := make(chan struct{}), make(chan struct{})
|
||||||
|
r := e.weekReply("unconfirmed", fullWeek...)
|
||||||
|
r.Started, r.Release = started, release
|
||||||
|
e.model.QueueGenerate(r)
|
||||||
|
e.reply("concurrent", reversed...)
|
||||||
|
unconfirmed, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
e.setStatus(t, plan.StatusSynced)
|
||||||
|
if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) {
|
||||||
|
t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
|
||||||
|
t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 2 {
|
||||||
|
t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued)
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
|
||||||
|
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked again once the unconfirmed job has finished, the confirmed
|
||||||
|
// draft replaces the plan.
|
||||||
|
if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) {
|
||||||
|
t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("first", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
|
||||||
|
// The household swaps Wednesday while draft_week runs; the draft must
|
||||||
|
// not erase the swap.
|
||||||
|
reversed := slices.Clone(fullWeek)
|
||||||
|
slices.Reverse(reversed)
|
||||||
|
started, release := make(chan struct{}), make(chan struct{})
|
||||||
|
r := e.weekReply("second", reversed...)
|
||||||
|
r.Started, r.Release = started, release
|
||||||
|
e.model.QueueGenerate(r)
|
||||||
|
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||||
|
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
job := e.waitJob(t, d.JobID)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged)
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := slices.Clone(fullWeek)
|
||||||
|
want[2] = "Fish Tacos"
|
||||||
|
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
|
||||||
|
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A draft after the swap replaces the plan.
|
||||||
|
e.reply("third", reversed...)
|
||||||
|
if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) {
|
||||||
|
t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("first", fullWeek...)
|
||||||
|
first := e.mustDraft(t, false)
|
||||||
|
reversed := slices.Clone(fullWeek)
|
||||||
|
slices.Reverse(reversed)
|
||||||
|
e.reply("second", reversed...)
|
||||||
|
second := e.mustDraft(t, false)
|
||||||
|
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
|
||||||
|
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
|
||||||
|
}
|
||||||
|
if got := dayNames(second); !slices.Equal(got, reversed) {
|
||||||
|
t.Errorf("days = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
||||||
|
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
|
||||||
|
t.Run(status, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("first", fullWeek...)
|
||||||
|
first := e.mustDraft(t, false)
|
||||||
|
e.setStatus(t, status)
|
||||||
|
// Push to Calendar records each day's event id.
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
|
||||||
|
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 1 {
|
||||||
|
t.Errorf("%d draft jobs, want only the first", queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
reversed := slices.Clone(fullWeek)
|
||||||
|
slices.Reverse(reversed)
|
||||||
|
e.reply("second", reversed...)
|
||||||
|
p := e.mustDraft(t, true)
|
||||||
|
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
|
||||||
|
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
|
||||||
|
}
|
||||||
|
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||||
|
t.Errorf("days = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
var events int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if events != 7 {
|
||||||
|
t.Errorf("%d days kept their calendar event id, want 7", events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("first", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
|
||||||
|
// The redraft is asked for while the plan is draft; the plan is synced
|
||||||
|
// while draft_week runs, so the draft must not replace it.
|
||||||
|
reversed := slices.Clone(fullWeek)
|
||||||
|
slices.Reverse(reversed)
|
||||||
|
started, release := make(chan struct{}), make(chan struct{})
|
||||||
|
r := e.weekReply("second", reversed...)
|
||||||
|
r.Started, r.Release = started, release
|
||||||
|
e.model.QueueGenerate(r)
|
||||||
|
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
e.setStatus(t, plan.StatusSynced)
|
||||||
|
close(release)
|
||||||
|
job := e.waitJob(t, d.JobID)
|
||||||
|
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
|
||||||
|
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
|
||||||
|
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWeekStart(t *testing.T) {
|
||||||
|
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
|
||||||
|
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
|
||||||
|
}
|
||||||
|
// 2026-02-30 and 2027-02-29 would be Mondays were they dates.
|
||||||
|
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} {
|
||||||
|
var invalid *plan.InvalidWeekError
|
||||||
|
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
|
||||||
|
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapDay(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||||
|
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
|
||||||
|
|
||||||
|
// A Quest log dinner is new, and a crock-pot one gets its start_by.
|
||||||
|
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
day := p.Days[2]
|
||||||
|
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
|
||||||
|
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusDraft {
|
||||||
|
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Regular in place of a Quest log dinner is not new.
|
||||||
|
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
|
||||||
|
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
|
||||||
|
}
|
||||||
|
want := slices.Clone(fullWeek)
|
||||||
|
want[2] = "Fish Tacos"
|
||||||
|
if got := dayNames(p); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("days = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
got, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(dayNames(got), want) {
|
||||||
|
t.Errorf("Get days = %v, want %v", dayNames(got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapStatusTransitions(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ from, want string }{
|
||||||
|
{plan.StatusDraft, plan.StatusDraft},
|
||||||
|
{plan.StatusSynced, plan.StatusChanged},
|
||||||
|
{plan.StatusChanged, plan.StatusChanged},
|
||||||
|
} {
|
||||||
|
t.Run(tc.from, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
e.setStatus(t, tc.from)
|
||||||
|
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||||
|
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Status != tc.want {
|
||||||
|
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapRefusals(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
e.reply("", fullWeek...)
|
||||||
|
e.mustDraft(t, false)
|
||||||
|
e.setStatus(t, plan.StatusSynced)
|
||||||
|
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
|
||||||
|
archived := e.dinner(t, "Old Stew", "archived", "", 0)
|
||||||
|
|
||||||
|
var notPlannable *plan.DinnerNotPlannableError
|
||||||
|
var badDay *plan.InvalidDayError
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
week store.LocalDate
|
||||||
|
date, id string
|
||||||
|
wantError func(error) bool
|
||||||
|
}{
|
||||||
|
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
|
||||||
|
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
|
||||||
|
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
|
||||||
|
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||||
|
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||||
|
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||||
|
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||||
|
{"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||||
|
} {
|
||||||
|
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
|
||||||
|
t.Errorf("%s: Swap = %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
|
||||||
|
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rotation check decisions (the rotation_reviews.decision column), the
|
||||||
|
// household's answer to "Keep it in rotation?" (D-021).
|
||||||
|
const (
|
||||||
|
// DecisionKeep makes the tried dinner a Regular.
|
||||||
|
DecisionKeep = "keep"
|
||||||
|
// DecisionDrop ("Not for us") archives it; its name stays among the
|
||||||
|
// passed names, so it is never suggested again.
|
||||||
|
DecisionDrop = "drop"
|
||||||
|
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
|
||||||
|
DecisionNotMade = "not_made"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decisionStatus is the status each decision moves a tried dinner to. Keep
|
||||||
|
// is the only way a tried dinner becomes a Regular: nothing automatic
|
||||||
|
// promotes one (D-021).
|
||||||
|
var decisionStatus = map[string]string{
|
||||||
|
DecisionKeep: dinners.StatusRegular,
|
||||||
|
DecisionDrop: dinners.StatusArchived,
|
||||||
|
DecisionNotMade: dinners.StatusQuestLog,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrReviewNotFound is a decision on a dinner that has never had a
|
||||||
|
// Rotation check: it was never tried.
|
||||||
|
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
|
||||||
|
|
||||||
|
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
|
||||||
|
// all decided, such as a second decision.
|
||||||
|
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
|
||||||
|
|
||||||
|
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
|
||||||
|
// is no longer tried, such as one archived meanwhile.
|
||||||
|
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
|
||||||
|
|
||||||
|
// InvalidDecisionError is a decision that is not keep, drop or not_made.
|
||||||
|
type InvalidDecisionError struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidDecisionError) Error() string {
|
||||||
|
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewDinner is the dinner of a Rotation check.
|
||||||
|
type ReviewDinner struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Photo *string `json:"photo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review is a pending Rotation check: the dinner tried and the plan date
|
||||||
|
// it was planned on.
|
||||||
|
type Review struct {
|
||||||
|
Dinner ReviewDinner `json:"dinner"`
|
||||||
|
// PlanDate is household-local, YYYY-MM-DD.
|
||||||
|
PlanDate string `json:"plan_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decided is a Rotation check as decided.
|
||||||
|
type Decided struct {
|
||||||
|
Dinner ReviewDinner `json:"dinner"`
|
||||||
|
PlanDate string `json:"plan_date"`
|
||||||
|
Decision string `json:"decision"`
|
||||||
|
// DinnerStatus is the status the decision moved the dinner to.
|
||||||
|
DinnerStatus string `json:"dinner_status"`
|
||||||
|
DecidedAt string `json:"decided_at"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkTried moves every dinner in the Quest log planned on date, the
|
||||||
|
// household-local day just past, on a plan of any status, to tried and
|
||||||
|
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||||
|
// dinner already checked for date is left as it is, so a later run never
|
||||||
|
// moves one returned to the Quest log ("We didn't make it") again. It
|
||||||
|
// returns how many dinners it moved.
|
||||||
|
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
ids, err := q.ListTriedCandidates(ctx, date.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
for _, id := range ids {
|
||||||
|
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||||
|
}
|
||||||
|
if moved != 1 {
|
||||||
|
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
|
||||||
|
}
|
||||||
|
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
|
||||||
|
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n = len(ids)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingReviews returns the pending Rotation checks, oldest plan date
|
||||||
|
// first.
|
||||||
|
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
|
||||||
|
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Review, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecideReview applies decision to the pending Rotation check of dinnerID
|
||||||
|
// and closes it, recording updatedBy: keep makes the dinner a Regular,
|
||||||
|
// drop archives it and not_made returns it to the Quest log; its origin is
|
||||||
|
// kept; a drop records the dinner's name on the check, so renaming the
|
||||||
|
// dinner later never takes it off the passed names. An unknown dinner is
|
||||||
|
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
|
||||||
|
// checks are all decided (such as a second decision) ErrNoPendingReview,
|
||||||
|
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
|
||||||
|
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
|
||||||
|
status, ok := decisionStatus[decision]
|
||||||
|
if !ok {
|
||||||
|
return Decided{}, &InvalidDecisionError{Value: decision}
|
||||||
|
}
|
||||||
|
if updatedBy == "" {
|
||||||
|
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
|
||||||
|
}
|
||||||
|
var out Decided
|
||||||
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
d, err := q.GetDinner(ctx, dinnerID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrDinnerNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
||||||
|
}
|
||||||
|
review, err := q.GetPendingRotationReview(ctx, d.ID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
checks, err := q.CountRotationReviews(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
|
||||||
|
}
|
||||||
|
if checks == 0 {
|
||||||
|
return ErrReviewNotFound
|
||||||
|
}
|
||||||
|
return ErrNoPendingReview
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
|
||||||
|
Status: status,
|
||||||
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: d.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
|
||||||
|
}
|
||||||
|
if moved != 1 {
|
||||||
|
return ErrDinnerNotTried
|
||||||
|
}
|
||||||
|
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
|
||||||
|
Decision: sql.NullString{String: decision, Valid: true},
|
||||||
|
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
|
||||||
|
Now: sql.NullString{String: now, Valid: true},
|
||||||
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
|
ID: review.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
|
||||||
|
}
|
||||||
|
if closed != 1 {
|
||||||
|
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
|
||||||
|
}
|
||||||
|
out = Decided{
|
||||||
|
Dinner: reviewDinner(d),
|
||||||
|
PlanDate: review.PlanDate,
|
||||||
|
Decision: decision,
|
||||||
|
DinnerStatus: status,
|
||||||
|
DecidedAt: now,
|
||||||
|
UpdatedBy: updatedBy,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Decided{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewDinner(d db.Dinner) ReviewDinner {
|
||||||
|
out := ReviewDinner{ID: d.ID, Name: d.Name}
|
||||||
|
if d.PhotoID.Valid {
|
||||||
|
u := dinners.PhotoURL(d.PhotoID.String)
|
||||||
|
out.Photo = &u
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviewer = "member-1"
|
||||||
|
|
||||||
|
// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned
|
||||||
|
// on these days of fullWeek.
|
||||||
|
var (
|
||||||
|
padThaiDay = mustDate("2026-10-14")
|
||||||
|
shawarmaDay = mustDate("2026-10-16")
|
||||||
|
)
|
||||||
|
|
||||||
|
// putWeek stores fullWeek as the plan of week with status, as a draft and
|
||||||
|
// Push to Calendar would.
|
||||||
|
func (e *env) putWeek(t *testing.T, status string) {
|
||||||
|
t.Helper()
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
id := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, name := range fullWeek {
|
||||||
|
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i],
|
||||||
|
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.setStatus(t, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
|
||||||
|
t.Helper()
|
||||||
|
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// review is a rotation_reviews row as stored.
|
||||||
|
type review struct {
|
||||||
|
planDate, status string
|
||||||
|
decision, name, decidedAt, updatedBy sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) reviews(t *testing.T, name string) []review {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := e.st.Reader.QueryContext(t.Context(),
|
||||||
|
`SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []review
|
||||||
|
for rows.Next() {
|
||||||
|
var r review
|
||||||
|
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) markTried(t *testing.T, date store.LocalDate, want int) {
|
||||||
|
t.Helper()
|
||||||
|
n, err := e.svc.MarkTried(t.Context(), date)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != want {
|
||||||
|
t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkTried(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
// Any plan status: a plan pushed to Calendar and edited still counts.
|
||||||
|
e.putWeek(t, plan.StatusChanged)
|
||||||
|
|
||||||
|
e.markTried(t, padThaiDay, 1)
|
||||||
|
if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" {
|
||||||
|
t.Errorf("Pad Thai status = %s, want tried", got)
|
||||||
|
}
|
||||||
|
got := e.reviews(t, "Pad Thai")
|
||||||
|
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
|
||||||
|
got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
|
||||||
|
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
|
||||||
|
}
|
||||||
|
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
|
||||||
|
t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: a second run moves nothing and opens no second check.
|
||||||
|
e.markTried(t, padThaiDay, 0)
|
||||||
|
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
|
||||||
|
t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day planning a Regular, or with no plan, moves nothing.
|
||||||
|
e.markTried(t, week, 0)
|
||||||
|
e.markTried(t, mustDate("2026-10-20"), 0)
|
||||||
|
if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" {
|
||||||
|
t.Errorf("Beef Chili status = %s, want regular", got)
|
||||||
|
}
|
||||||
|
if got := e.reviews(t, "Beef Chili"); len(got) != 0 {
|
||||||
|
t.Errorf("Beef Chili reviews = %+v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dinner returned to the Quest log ("We didn't make it") is not marked
|
||||||
|
// tried again for the same day.
|
||||||
|
func TestMarkTriedAfterNotMade(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.putWeek(t, plan.StatusDraft)
|
||||||
|
e.markTried(t, padThaiDay, 1)
|
||||||
|
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.markTried(t, padThaiDay, 0)
|
||||||
|
if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" {
|
||||||
|
t.Errorf("Pad Thai status = %s, want quest_log", got)
|
||||||
|
}
|
||||||
|
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" {
|
||||||
|
t.Errorf("Pad Thai reviews = %+v, want the one decided", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingReviews(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.putWeek(t, plan.StatusDraft)
|
||||||
|
got, err := e.svc.PendingReviews(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("pending before MarkTried = %+v, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.markTried(t, shawarmaDay, 1)
|
||||||
|
e.markTried(t, padThaiDay, 1)
|
||||||
|
got, err = e.svc.PendingReviews(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []plan.Review{
|
||||||
|
{Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()},
|
||||||
|
{Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()},
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("pending = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideReview(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
decision, status string
|
||||||
|
}{
|
||||||
|
{plan.DecisionKeep, "regular"},
|
||||||
|
{plan.DecisionDrop, "archived"},
|
||||||
|
{plan.DecisionNotMade, "quest_log"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.decision, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.putWeek(t, plan.StatusDraft)
|
||||||
|
e.markTried(t, padThaiDay, 1)
|
||||||
|
|
||||||
|
d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() ||
|
||||||
|
d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer {
|
||||||
|
t.Errorf("decided = %+v", d)
|
||||||
|
}
|
||||||
|
row := e.dinnerRow(t, "Pad Thai")
|
||||||
|
if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer {
|
||||||
|
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
|
||||||
|
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
|
||||||
|
}
|
||||||
|
// Only a drop records the dinner's name.
|
||||||
|
wantPassed := tc.decision == plan.DecisionDrop
|
||||||
|
wantName := sql.NullString{}
|
||||||
|
if wantPassed {
|
||||||
|
wantName = sql.NullString{String: "Pad Thai", Valid: true}
|
||||||
|
}
|
||||||
|
got := e.reviews(t, "Pad Thai")
|
||||||
|
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
|
||||||
|
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
|
||||||
|
got[0].name != wantName {
|
||||||
|
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
|
||||||
|
}
|
||||||
|
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
|
||||||
|
t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second decision, of any kind, changes nothing.
|
||||||
|
for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} {
|
||||||
|
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) {
|
||||||
|
t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status {
|
||||||
|
t.Errorf("status after a second decision = %s, want %s", got, tc.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
passed, err := db.New(e.st.Reader).ListPassedNames(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
|
||||||
|
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideReviewRefused(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.putWeek(t, plan.StatusDraft)
|
||||||
|
e.markTried(t, padThaiDay, 1)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
var invalid *plan.InvalidDecisionError
|
||||||
|
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) {
|
||||||
|
t.Errorf("decision promote: %v, want *InvalidDecisionError", err)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) {
|
||||||
|
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
|
||||||
|
}
|
||||||
|
// Never tried: Shawarma Bowls is in the Quest log with no check.
|
||||||
|
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
|
||||||
|
t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
|
||||||
|
}
|
||||||
|
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
|
||||||
|
t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil {
|
||||||
|
t.Error("decision with no member succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tried dinner archived meanwhile is neither kept nor listed.
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) {
|
||||||
|
t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err)
|
||||||
|
}
|
||||||
|
if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" {
|
||||||
|
t.Errorf("Pad Thai status = %s, want archived", got)
|
||||||
|
}
|
||||||
|
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
|
||||||
|
t.Errorf("Pad Thai reviews = %+v, want the one still pending", got)
|
||||||
|
}
|
||||||
|
if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 {
|
||||||
|
t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation
|
||||||
|
// check's keep may make an existing dinner a Regular. Every query writing
|
||||||
|
// dinners.status and every non-test use of the regular status must be one
|
||||||
|
// of those reviewed below; a new one fails here until it is reviewed.
|
||||||
|
func TestOnlyKeepPromotesToRegular(t *testing.T) {
|
||||||
|
// Each query writing dinners.status, and why it cannot promote.
|
||||||
|
statusWriters := map[string]string{
|
||||||
|
"CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular",
|
||||||
|
"UpdateDinner": "dinners.Service.Update writes the status it read back",
|
||||||
|
"ArchiveDinner": "sets archived",
|
||||||
|
"MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived",
|
||||||
|
"MarkDinnerTried": "sets tried",
|
||||||
|
"MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular",
|
||||||
|
}
|
||||||
|
files, err := filepath.Glob("../../migrations/queries/*.sql")
|
||||||
|
if err != nil || len(files) == 0 {
|
||||||
|
t.Fatalf("no query files: %v", err)
|
||||||
|
}
|
||||||
|
writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`)
|
||||||
|
nameRe := regexp.MustCompile(`^\s*(\w+)`)
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, f := range files {
|
||||||
|
src, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, stmt := range strings.Split(string(src), "-- name:")[1:] {
|
||||||
|
name := nameRe.FindStringSubmatch(stmt)[1]
|
||||||
|
var body []string
|
||||||
|
for _, line := range strings.Split(stmt, "\n")[1:] {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||||
|
body = append(body, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlText := strings.Join(body, "\n")
|
||||||
|
if !writes.MatchString(sqlText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found[name] = true
|
||||||
|
if _, ok := statusWriters[name]; !ok {
|
||||||
|
t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name)
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) {
|
||||||
|
t.Errorf("%s: query %s sets status regular outright", f, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range statusWriters {
|
||||||
|
if !found[name] {
|
||||||
|
t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each non-test Go file naming the regular status, and how often.
|
||||||
|
uses := map[string]int{
|
||||||
|
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
|
||||||
|
"plan/plan.go": 1, // plannable
|
||||||
|
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
|
||||||
|
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
|
||||||
|
"ai/harness/context.go": 1, // reads the Regulars
|
||||||
|
"ai/skills/draft_week.go": 1, // KindRegular, a planned day's label
|
||||||
|
}
|
||||||
|
regular := regexp.MustCompile(`StatusRegular\b|"regular"`)
|
||||||
|
got := map[string]int{}
|
||||||
|
err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() && d.Name() == "db" {
|
||||||
|
return filepath.SkipDir // generated; the queries are checked above
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n := len(regular.FindAllIndex(src, -1)); n > 0 {
|
||||||
|
got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, n := range got {
|
||||||
|
if uses[path] != n {
|
||||||
|
t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, n := range uses {
|
||||||
|
if got[path] == 0 {
|
||||||
|
t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), newTestDinners(t), newTestSuggest(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,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),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ type jobCreated struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
// that is not service-only and returns 202 with its id, which
|
||||||
|
// GET /api/v1/jobs/{id} reports on.
|
||||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||||
@@ -43,6 +44,9 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
|||||||
case errors.Is(err, jobs.ErrUnknownKind):
|
case errors.Is(err, jobs.ErrUnknownKind):
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||||
return
|
return
|
||||||
|
case errors.Is(err, jobs.ErrServiceOnly):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint")
|
||||||
|
return
|
||||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
|
||||||
|
// /plans/{weekStart}/days/{date} body.
|
||||||
|
const maxPlanBody = 4 << 10
|
||||||
|
|
||||||
|
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
|
||||||
|
// to draft over a plan pushed to Calendar.
|
||||||
|
type planDraft struct {
|
||||||
|
Confirm *bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
|
||||||
|
type planDaySwap struct {
|
||||||
|
DinnerID *string `json:"dinner_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPlan answers GET /plans/{weekStart} with the week's plan.
|
||||||
|
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
week, ok := weekStartOrFail(w, r, logger)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := svc.Get(r.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
writePlanError(w, r, logger, "read plan", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postPlanDraft enqueues the draft of the week and answers 202 with the
|
||||||
|
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
|
||||||
|
// a draft of the week is already queued or running, it answers 202 with
|
||||||
|
// that job instead, or 409 when this draft is confirmed and that one is
|
||||||
|
// not.
|
||||||
|
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
week, ok := weekStartOrFail(w, r, logger)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
|
||||||
|
if err != nil {
|
||||||
|
writePlanError(w, r, logger, "start plan draft", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusAccepted, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPlanDay swaps the dinner of one day of the week's plan and answers
|
||||||
|
// with the plan as swapped.
|
||||||
|
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
week, ok := weekStartOrFail(w, r, logger)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DinnerID == nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
|
||||||
|
if err != nil {
|
||||||
|
writePlanError(w, r, logger, "swap plan day", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
|
||||||
|
// when it is not a Monday date.
|
||||||
|
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
||||||
|
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
|
||||||
|
return store.LocalDate{}, false
|
||||||
|
}
|
||||||
|
return week, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePlanError maps a plan.Service error to its API error: a week with
|
||||||
|
// no plan or an unknown dinner is 404, a date outside the week is 400, and
|
||||||
|
// drafting over a synced plan without confirm, a confirmed draft while an
|
||||||
|
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
|
||||||
|
// the Quest log, or one already on another day of the week, is 409.
|
||||||
|
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||||
|
var day *plan.InvalidDayError
|
||||||
|
var week *plan.InvalidWeekError
|
||||||
|
var notPlannable *plan.DinnerNotPlannableError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &day):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
|
||||||
|
case errors.As(err, &week):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
|
||||||
|
case errors.As(err, ¬Plannable):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
|
||||||
|
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
|
||||||
|
case errors.Is(err, plan.ErrConfirmRequired):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
|
||||||
|
case errors.Is(err, plan.ErrDraftRunning):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
|
||||||
|
case errors.Is(err, plan.ErrNotFound):
|
||||||
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
|
||||||
|
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||||
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||||
|
default:
|
||||||
|
logger.ErrorContext(r.Context(), action, "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const plansPath = "/api/v1/plans/"
|
||||||
|
|
||||||
|
// plansEnv is a router whose plan service has an unstarted job runner, so
|
||||||
|
// drafts stay queued, over a store holding a draft plan of the week of
|
||||||
|
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
|
||||||
|
// on every other day. questLog is the id of a Quest log dinner on no plan.
|
||||||
|
type plansEnv struct {
|
||||||
|
h http.Handler
|
||||||
|
st *store.Store
|
||||||
|
svc *plan.Service
|
||||||
|
questLog string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPlansRouter(t *testing.T) plansEnv {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := household.SeedSettings(ctx, 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)
|
||||||
|
}
|
||||||
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
svc, err := plan.NewService(st, fail, runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
|
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := db.New(st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
dinner := func(name, status, method string, minutes int64) string {
|
||||||
|
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||||
|
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
|
||||||
|
Method: sql.NullString{String: method, Valid: method != ""},
|
||||||
|
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||||
|
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
planID := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
|
||||||
|
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
|
||||||
|
id := dinner("Regular "+date, "regular", "stovetop", 30)
|
||||||
|
if i == 0 {
|
||||||
|
id = dinner("Beef Chili", "regular", "crockpot", 240)
|
||||||
|
}
|
||||||
|
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 plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPlan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
|
||||||
|
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
|
||||||
|
t.Errorf("plan = %v", body)
|
||||||
|
}
|
||||||
|
days, _ := body["days"].([]any)
|
||||||
|
if len(days) != 7 {
|
||||||
|
t.Fatalf("days = %v, want 7", body["days"])
|
||||||
|
}
|
||||||
|
monday := days[0].(map[string]any)
|
||||||
|
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
|
||||||
|
t.Errorf("Monday = %v", monday)
|
||||||
|
}
|
||||||
|
d := monday["dinner"].(map[string]any)
|
||||||
|
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
|
||||||
|
if _, ok := d[key]; !ok {
|
||||||
|
t.Errorf("dinner has no %s: %v", key, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
|
||||||
|
t.Errorf("Monday dinner = %v", d)
|
||||||
|
}
|
||||||
|
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
|
||||||
|
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
|
||||||
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||||
|
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWeekStartMustBeMonday(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ method, target, body string }{
|
||||||
|
{http.MethodGet, plansPath + "2026-10-13", ""},
|
||||||
|
{http.MethodGet, plansPath + "not-a-date", ""},
|
||||||
|
{http.MethodGet, plansPath + "2026-02-30", ""},
|
||||||
|
{http.MethodPost, plansPath + "2027-02-29/draft", "{}"},
|
||||||
|
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
|
||||||
|
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
|
||||||
|
} {
|
||||||
|
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
|
||||||
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
|
||||||
|
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostPlanDraft(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
|
||||||
|
if rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
jobID, _ := body["jobId"].(string)
|
||||||
|
if jobID == "" || len(body) != 1 {
|
||||||
|
t.Fatalf("POST draft = %v, want only a jobId", body)
|
||||||
|
}
|
||||||
|
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
||||||
|
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
|
||||||
|
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
|
||||||
|
}
|
||||||
|
// A second draft of the week joins the queued one.
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
|
||||||
|
if rec.Code != http.StatusAccepted || body["jobId"] != jobID {
|
||||||
|
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
|
||||||
|
}
|
||||||
|
// A week with no plan needs no confirm.
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
|
||||||
|
if rec.Code != http.StatusAccepted || body["jobId"] == jobID {
|
||||||
|
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A confirmed draft does not join the unconfirmed one and enqueues
|
||||||
|
// nothing.
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
|
||||||
|
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
|
||||||
|
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 2 {
|
||||||
|
t.Errorf("%d draft jobs, want one per week", queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||||
|
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a
|
||||||
|
// draft_week job, so a confirmed draft cannot run beside the week's
|
||||||
|
// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start
|
||||||
|
// a draft.
|
||||||
|
func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
rec, _ := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
|
||||||
|
if rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs",
|
||||||
|
`{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`)
|
||||||
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||||
|
t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body)
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 1 {
|
||||||
|
t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
const n = 10
|
||||||
|
recs := make([]*httptest.ResponseRecorder, n)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
recs[i] = httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
jobIDs := map[any]int{}
|
||||||
|
for i, rec := range recs {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
jobIDs[body["jobId"]]++
|
||||||
|
}
|
||||||
|
var queued int
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 {
|
||||||
|
t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, status := range []string{"synced", "changed"} {
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
e.setStatus(t, status)
|
||||||
|
for _, b := range []string{"{}", `{"confirm":false}`} {
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
|
||||||
|
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
|
||||||
|
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
|
||||||
|
if rec.Code != http.StatusAccepted || body["jobId"] == nil {
|
||||||
|
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutPlanDay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
e.setStatus(t, "synced")
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if body["status"] != "changed" {
|
||||||
|
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
|
||||||
|
}
|
||||||
|
wednesday := body["days"].([]any)[2].(map[string]any)
|
||||||
|
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
|
||||||
|
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
target, body string
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
|
||||||
|
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
|
||||||
|
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
|
||||||
|
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
|
||||||
|
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
|
||||||
|
} {
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
|
||||||
|
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||||
|
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
|
||||||
|
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
|
||||||
|
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
|
||||||
|
const maxRotationReviewBody = 1 << 10
|
||||||
|
|
||||||
|
// rotationDecision is the POST /dinners/{id}/rotation-review body:
|
||||||
|
// keep, drop ("Not for us") or not_made ("We didn't make it").
|
||||||
|
type rotationDecision struct {
|
||||||
|
Decision *string `json:"decision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rotationReviewList struct {
|
||||||
|
Reviews []plan.Review `json:"reviews"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRotationReviews answers GET /rotation-reviews with the pending
|
||||||
|
// Rotation checks, oldest plan date first.
|
||||||
|
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := svc.PendingReviews(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeRotationError(w, r, logger, "list Rotation checks", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postRotationReview applies the household's answer to "Keep it in
|
||||||
|
// rotation?" for a tried dinner, recording the session's actor as
|
||||||
|
// updated_by, and answers with the Rotation check as decided.
|
||||||
|
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor, ok := actorOrFail(w, r, logger)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Decision == nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
|
||||||
|
if err != nil {
|
||||||
|
writeRotationError(w, r, logger, "decide Rotation check", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRotationError maps a Rotation check error to its API error: a
|
||||||
|
// decision other than keep, drop or not_made is 400, an unknown dinner or
|
||||||
|
// a dinner never checked 404, and a dinner whose checks are all decided
|
||||||
|
// (such as a second decision) or no longer tried 409.
|
||||||
|
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||||
|
var invalid *plan.InvalidDecisionError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &invalid):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
|
||||||
|
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||||
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||||
|
case errors.Is(err, plan.ErrReviewNotFound):
|
||||||
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
|
||||||
|
case errors.Is(err, plan.ErrNoPendingReview):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
|
||||||
|
case errors.Is(err, plan.ErrDinnerNotTried):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
|
||||||
|
default:
|
||||||
|
logger.ErrorContext(r.Context(), action, "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rotationReviewsPath = "/api/v1/rotation-reviews"
|
||||||
|
|
||||||
|
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
|
||||||
|
// and marks the day tried, so Pad Thai has a pending Rotation check.
|
||||||
|
func triedPadThai(t *testing.T, e plansEnv) {
|
||||||
|
t.Helper()
|
||||||
|
rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
day, err := store.ParseLocalDate("2026-10-14")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
|
||||||
|
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRotationReviews(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
|
||||||
|
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
triedPadThai(t, e)
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
reviews, _ := body["reviews"].([]any)
|
||||||
|
if len(reviews) != 1 {
|
||||||
|
t.Fatalf("reviews = %v, want one", body)
|
||||||
|
}
|
||||||
|
r := reviews[0].(map[string]any)
|
||||||
|
d, _ := r["dinner"].(map[string]any)
|
||||||
|
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
|
||||||
|
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
|
||||||
|
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostRotationReview(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, tc := range []struct{ decision, status string }{
|
||||||
|
{"keep", "regular"},
|
||||||
|
{"drop", "archived"},
|
||||||
|
{"not_made", "quest_log"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.decision, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
triedPadThai(t, e)
|
||||||
|
target := dinnersPath + "/" + e.questLog + "/rotation-review"
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
|
||||||
|
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
|
||||||
|
t.Errorf("decided = %v", body)
|
||||||
|
}
|
||||||
|
var status, origin, updatedBy string
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
|
||||||
|
Scan(&status, &origin, &updatedBy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
|
||||||
|
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
|
||||||
|
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
|
||||||
|
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
|
||||||
|
}
|
||||||
|
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||||
|
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
|
||||||
|
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dinner that has never had a Rotation check is 404; a second decision
|
||||||
|
// on one already decided is 409 (TestPostRotationReview).
|
||||||
|
func TestPostRotationReviewNeverChecked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
|
||||||
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||||
|
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "quest_log" {
|
||||||
|
t.Errorf("status after a refused decision = %s, want quest_log", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostRotationReviewRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
triedPadThai(t, e)
|
||||||
|
target := dinnersPath + "/" + e.questLog + "/rotation-review"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, target, body string
|
||||||
|
code int
|
||||||
|
errCode string
|
||||||
|
}{
|
||||||
|
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
|
||||||
|
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
|
||||||
|
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
|
||||||
|
} {
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
|
||||||
|
if rec.Code != tc.code || errorCode(body) != tc.errCode {
|
||||||
|
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "tried" {
|
||||||
|
t.Errorf("status after refused decisions = %s, want tried", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"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/household"
|
"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/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
)
|
)
|
||||||
@@ -41,12 +42,15 @@ type RouteMounter interface {
|
|||||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||||
// /decline; runner serves
|
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||||
|
// /api/v1/plans/{weekStart}/draft, PUT
|
||||||
|
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||||
|
// POST /api/v1/dinners/{id}/rotation-review; 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, dinnerSvc *dinners.Service, suggestSvc *suggest.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, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.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")
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if suggestSvc == nil {
|
if suggestSvc == nil {
|
||||||
return nil, errors.New("server: nil suggestions service")
|
return nil, errors.New("server: nil suggestions service")
|
||||||
}
|
}
|
||||||
|
if planSvc == nil {
|
||||||
|
return nil, errors.New("server: nil plan service")
|
||||||
|
}
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -116,6 +123,11 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
||||||
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
||||||
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
||||||
|
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||||
|
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||||
|
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||||
|
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||||
|
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||||
apiV1.Post("/jobs", postJob(logger, runner))
|
apiV1.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"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/household"
|
"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/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"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/secretbox"
|
||||||
@@ -49,7 +50,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), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
|
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -185,6 +186,26 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestPlan returns a plan service over a migrated temp database, with
|
||||||
|
// its own unstarted job runner and a drafter that always fails.
|
||||||
|
func newTestPlan(t *testing.T) *plan.Service {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
|
}
|
||||||
|
svc, err := plan.NewService(st, fail, runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
// newTestBox returns a secret box under a random key.
|
// newTestBox returns a secret box under a random key.
|
||||||
func newTestBox(t *testing.T) *secretbox.Box {
|
func newTestBox(t *testing.T) *secretbox.Box {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -216,7 +237,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, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +246,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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +255,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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted nil photo files")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +264,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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +273,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), nil, newTestSuggest(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, newTestSuggest(t), newTestPlan(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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,16 +282,25 @@ 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), newTestDinners(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), newTestDinners(t), nil, newTestPlan(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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewRouterRequiresPlan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
|
t.Fatal("NewRouter accepted a nil plan service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
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), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,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),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
@@ -633,3 +633,88 @@ func TestMigrateSuggestionName(t *testing.T) {
|
|||||||
t.Error("suggestions_copy left behind")
|
t.Error("suggestions_copy left behind")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMigrateRotationReviewName upgrades a database at 0013 holding a
|
||||||
|
// pending, a kept and a dropped Rotation check and checks 0014 keeps all
|
||||||
|
// three, gives only the drop its dinner's name, and refuses a drop with no
|
||||||
|
// name or a name on any other check.
|
||||||
|
func TestMigrateRotationReviewName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := openTestStore(t)
|
||||||
|
before := fstest.MapFS{}
|
||||||
|
for _, name := range []string{
|
||||||
|
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
|
||||||
|
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
|
||||||
|
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
|
||||||
|
"0011_ground_dinner_job_revision.sql", "0012_suggestion_name.sql", "0013_week_plan_revision.sql",
|
||||||
|
} {
|
||||||
|
body, err := fs.ReadFile(migrations.FS, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before[name] = &fstest.MapFile{Data: body}
|
||||||
|
}
|
||||||
|
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||||
|
VALUES ('dinner-1', 'Pho', 'pho', 'tried', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||||
|
('dinner-2', 'Ramen', 'ramen', 'regular', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||||
|
('dinner-3', 'Laksa', 'laksa', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
|
||||||
|
`INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at)
|
||||||
|
VALUES ('review-1', 'dinner-1', '2026-10-12', 'pending', NULL, NULL, NULL, 'c1', 'u1'),
|
||||||
|
('review-2', 'dinner-2', '2026-10-13', 'decided', 'keep', 'd2', 'member-1', 'c2', 'u2'),
|
||||||
|
('review-3', 'dinner-3', '2026-10-14', 'decided', 'drop', 'd3', 'member-1', 'c3', 'u3')`,
|
||||||
|
} {
|
||||||
|
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.Reader.Query(`SELECT id, dinner_id, plan_date, status, COALESCE(decision, ''), COALESCE(name, ''),
|
||||||
|
COALESCE(decided_at, ''), COALESCE(updated_by, ''), created_at, updated_at
|
||||||
|
FROM rotation_reviews ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got [][]string
|
||||||
|
for rows.Next() {
|
||||||
|
r := make([]string, 10)
|
||||||
|
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = append(got, r)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := [][]string{
|
||||||
|
{"review-1", "dinner-1", "2026-10-12", "pending", "", "", "", "", "c1", "u1"},
|
||||||
|
{"review-2", "dinner-2", "2026-10-13", "decided", "keep", "", "d2", "member-1", "c2", "u2"},
|
||||||
|
{"review-3", "dinner-3", "2026-10-14", "decided", "drop", "Laksa", "d3", "member-1", "c3", "u3"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("rotation_reviews after 0014 =\n %v\nwant\n %v", got, want)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ name, stmt, want string }{
|
||||||
|
{"drop with no name", `UPDATE rotation_reviews SET name = NULL WHERE id = 'review-3'`, "CHECK constraint failed"},
|
||||||
|
{"empty name", `UPDATE rotation_reviews SET name = '' WHERE id = 'review-3'`, "CHECK constraint failed"},
|
||||||
|
{"name on a keep", `UPDATE rotation_reviews SET name = 'Ramen' WHERE id = 'review-2'`, "CHECK constraint failed"},
|
||||||
|
{"name on a pending check", `UPDATE rotation_reviews SET name = 'Pho' WHERE id = 'review-1'`, "CHECK constraint failed"},
|
||||||
|
{"unknown dinner", `UPDATE rotation_reviews SET dinner_id = 'nope' WHERE id = 'review-1'`, "FOREIGN KEY constraint failed"},
|
||||||
|
{"second check of a dinner and date", `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||||
|
VALUES ('review-4', 'dinner-1', '2026-10-12', 'pending', 'c', 'u')`, "UNIQUE constraint failed"},
|
||||||
|
} {
|
||||||
|
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tableExists(t, s, "rotation_reviews_copy") {
|
||||||
|
t.Error("rotation_reviews_copy left behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type batchResult struct {
|
|||||||
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||||
|
|
||||||
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
|
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
|
||||||
// Side quests given the household context, the deck and every declined
|
// Side quests given the household context, the deck and every passed
|
||||||
// name, then grounds them in the model's order, at most the number still
|
// name, then grounds them in the model's order, at most the number still
|
||||||
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
||||||
// no recipe grounds is dropped; any other grounding failure fails the
|
// no recipe grounds is dropped; any other grounding failure fails the
|
||||||
@@ -132,7 +132,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// request reads what suggest_side_quests is given: the household context,
|
// request reads what suggest_side_quests is given: the household context,
|
||||||
// the deck's names and every declined name. A household with no Regulars
|
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||||
// fails with ReasonNoRegulars.
|
// fails with ReasonNoRegulars.
|
||||||
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
|
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
|
||||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||||
@@ -151,9 +151,9 @@ func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error)
|
|||||||
for _, row := range deck {
|
for _, row := range deck {
|
||||||
pending = append(pending, row.Dinner.Name)
|
pending = append(pending, row.Dinner.Name)
|
||||||
}
|
}
|
||||||
declined, err := q.ListDeclinedSuggestionNames(ctx)
|
declined, err := q.ListPassedNames(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
|
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err)
|
||||||
}
|
}
|
||||||
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
|
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
|
||||||
}
|
}
|
||||||
@@ -201,9 +201,9 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
|||||||
// suggestion citing its Regulars by id, all in one transaction, and
|
// suggestion citing its Regulars by id, all in one transaction, and
|
||||||
// returns the suggestion ids. A Side quest is left out when, since
|
// returns the suggestion ids. A Side quest is left out when, since
|
||||||
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||||
// repeats passed, or a Regular it cites renamed or archived. Declined
|
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
||||||
// names are read in the same transaction as the store, so a Pass committed
|
// or archived. Passed names are read in the same transaction as the store,
|
||||||
// before it is never missed.
|
// so a Pass or drop committed before it is never missed.
|
||||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -212,9 +212,9 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||||
}
|
}
|
||||||
names, err := q.ListDeclinedSuggestionNames(ctx)
|
names, err := q.ListPassedNames(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
|
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||||
}
|
}
|
||||||
regulars := map[string][]string{}
|
regulars := map[string][]string{}
|
||||||
for _, row := range existing {
|
for _, row := range existing {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"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/household"
|
"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/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -646,7 +647,7 @@ func TestDeclineIsIdempotent(t *testing.T) {
|
|||||||
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
|
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
|
||||||
t.Errorf("dinner status after refused accept = %s, want archived", got)
|
t.Errorf("dinner status after refused accept = %s, want archived", got)
|
||||||
}
|
}
|
||||||
names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context())
|
names, err := db.New(e.st.Reader).ListPassedNames(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -912,6 +913,74 @@ func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its
|
||||||
|
// Rotation check ("Not for us"), renames its archived dinner, and checks
|
||||||
|
// the next batch still refuses the name it was dropped under.
|
||||||
|
func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
|
return skills.Week{}, errors.New("no draft in this test")
|
||||||
|
}
|
||||||
|
planSvc, err := plan.NewService(e.st, fail, e.runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := e.dinner(t, "Pad Thai", "quest_log")
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
planID := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: "2026-10-14",
|
||||||
|
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
day, err := store.ParseLocalDate("2026-10-14")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, err := planSvc.MarkTried(ctx, day); err != nil || n != 1 {
|
||||||
|
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
|
||||||
|
}
|
||||||
|
if _, err := planSvc.DecideReview(ctx, id, plan.DecisionDrop, member); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
renamed := "Peanut Noodles"
|
||||||
|
if _, err := dinnerSvc.Update(ctx, id, dinners.Patch{Name: &renamed}, member); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The respelled dropped dish would ground, so only the passed names
|
||||||
|
// keep it out of the deck.
|
||||||
|
e.recipe(t, "pad thai!")
|
||||||
|
e.recipe(t, "Beef Ragu")
|
||||||
|
e.reply(
|
||||||
|
sideQuest("pad thai!", "Noodles, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||||
|
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
|
||||||
|
)
|
||||||
|
e.start(t)
|
||||||
|
res := e.runBatch(t)
|
||||||
|
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
|
||||||
|
t.Errorf("batch result = %+v, want 1 kept and the dropped dish dropped", res)
|
||||||
|
}
|
||||||
|
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
|
||||||
|
t.Errorf("deck = %v, want [Beef Ragu]", got)
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
in := e.inputs[0]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if !slices.Equal(in.Declined, []string{"Pad Thai"}) || !slices.Equal(in.Context.Passed, []string{"Pad Thai"}) {
|
||||||
|
t.Errorf("batch declined %v, passed %v; want the name it was dropped under in both", in.Declined, in.Context.Passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
|
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
|
||||||
// job fail to store: the decision must not be recorded either, so the
|
// job fail to store: the decision must not be recorded either, so the
|
||||||
// retry decides again and starts the replenish.
|
// retry decides again and starts the replenish.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 0013_week_plan_revision: every edit of a week's plan — a swap or a
|
||||||
|
-- stored draft — raises its revision. A draft_week job reads the revision
|
||||||
|
-- before draft_week runs and stores its week only while the plan still
|
||||||
|
-- has it, so a draft never overwrites an edit made while the AI ran.
|
||||||
|
|
||||||
|
ALTER TABLE week_plans ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us")
|
||||||
|
-- keeps the name its dinner had when the household dropped it. The dinner
|
||||||
|
-- can be renamed, even once archived, so the passed names every later
|
||||||
|
-- batch refuses are read from here, never from the dinner, as 0012 does
|
||||||
|
-- for a Pass. Only a drop has a name. A check dropped before this takes
|
||||||
|
-- its dinner's name now.
|
||||||
|
-- SQLite cannot add a column with a CHECK that existing drops would fail
|
||||||
|
-- before they are filled in, so the table is rebuilt as 0012 does: copy
|
||||||
|
-- the rows aside, drop the table, create it again, copy the rows back and
|
||||||
|
-- drop the copy. Nothing references rotation_reviews.
|
||||||
|
|
||||||
|
CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews;
|
||||||
|
|
||||||
|
DROP TABLE rotation_reviews;
|
||||||
|
|
||||||
|
CREATE TABLE rotation_reviews (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||||
|
plan_date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
|
||||||
|
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
|
||||||
|
name TEXT CHECK (name <> ''), -- the dinner's name when dropped
|
||||||
|
decided_at TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (dinner_id, plan_date),
|
||||||
|
CHECK ((decision IS 'drop') = (name IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO rotation_reviews (
|
||||||
|
id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date,
|
||||||
|
rotation_reviews_copy.status, rotation_reviews_copy.decision,
|
||||||
|
CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END,
|
||||||
|
rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by,
|
||||||
|
rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at
|
||||||
|
FROM rotation_reviews_copy
|
||||||
|
JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id;
|
||||||
|
|
||||||
|
DROP TABLE rotation_reviews_copy;
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
-- Reads for the AI household context block (internal/ai/harness).
|
-- Reads for the AI household context block (internal/ai/harness).
|
||||||
|
|
||||||
-- name: ListRecentDeclinedNames :many
|
-- name: ListRecentPassedNames :many
|
||||||
-- The names the most recently declined suggestions were suggested under,
|
-- The most recent names the household turned down, newest first, as
|
||||||
-- newest first; renaming their dinners does not change them.
|
-- ListPassedNames has them.
|
||||||
SELECT name
|
SELECT name FROM (
|
||||||
FROM suggestions
|
SELECT name, decided_at, id
|
||||||
WHERE status = 'declined'
|
FROM suggestions
|
||||||
|
WHERE status = 'declined'
|
||||||
|
UNION ALL
|
||||||
|
SELECT name, decided_at, id
|
||||||
|
FROM rotation_reviews
|
||||||
|
WHERE decision = 'drop'
|
||||||
|
)
|
||||||
ORDER BY decided_at DESC, id DESC
|
ORDER BY decided_at DESC, id DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- name: GetWeekPlan :one
|
||||||
|
-- The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||||
|
-- there is none.
|
||||||
|
SELECT * FROM week_plans
|
||||||
|
WHERE week_start = ?;
|
||||||
|
|
||||||
|
-- name: ListPlanDays :many
|
||||||
|
-- The days of plan plan_id with their dinners, Monday first. A day with
|
||||||
|
-- no dinner is left out.
|
||||||
|
SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners)
|
||||||
|
FROM plan_days
|
||||||
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
|
WHERE plan_days.plan_id = ?
|
||||||
|
ORDER BY plan_days.date;
|
||||||
|
|
||||||
|
-- name: CreateWeekPlan :exec
|
||||||
|
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'draft', ?, ?, ?);
|
||||||
|
|
||||||
|
-- name: RedraftWeekPlan :exec
|
||||||
|
-- Records a new draft over plan id: its status and note, raising its
|
||||||
|
-- revision.
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: PutPlanDay :exec
|
||||||
|
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||||
|
-- when the day exists.
|
||||||
|
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (plan_id, date) DO UPDATE
|
||||||
|
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at;
|
||||||
|
|
||||||
|
-- name: SwapPlanDay :execrows
|
||||||
|
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||||
|
-- have is left as it is (0 rows).
|
||||||
|
UPDATE plan_days
|
||||||
|
SET dinner_id = ?, is_new = ?, updated_at = ?
|
||||||
|
WHERE plan_id = ? AND date = ?;
|
||||||
|
|
||||||
|
-- name: MarkWeekPlanEdited :exec
|
||||||
|
-- Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
|
-- changed, any other keeps its status.
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||||
|
revision = revision + 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: ListDraftPool :many
|
||||||
|
-- The dinners a draft of the week starting week_start may plan: every
|
||||||
|
-- Regular, and every Quest log dinner no other plan for the week starting
|
||||||
|
-- from_week_start or later has on any day.
|
||||||
|
SELECT * FROM dinners
|
||||||
|
WHERE status = 'regular'
|
||||||
|
OR (status = 'quest_log' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE plan_days.dinner_id = dinners.id
|
||||||
|
AND week_plans.week_start >= sqlc.arg(from_week_start)
|
||||||
|
AND week_plans.week_start <> sqlc.arg(week_start)
|
||||||
|
))
|
||||||
|
ORDER BY normalized_name, id;
|
||||||
|
|
||||||
|
-- name: ListWeekPlanDinnerIDs :many
|
||||||
|
-- The ids of the dinners planned in the week starting week_start, Monday
|
||||||
|
-- first.
|
||||||
|
SELECT plan_days.dinner_id
|
||||||
|
FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
|
||||||
|
ORDER BY plan_days.date;
|
||||||
|
|
||||||
|
-- name: GetActiveDraftJob :one
|
||||||
|
-- The job of kind queued or running for the week starting week_start,
|
||||||
|
-- with whether its payload has confirm. StartDraft enqueues a draft only
|
||||||
|
-- when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||||
|
-- there is none.
|
||||||
|
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
|
||||||
|
FROM jobs
|
||||||
|
WHERE kind = sqlc.arg(kind)
|
||||||
|
AND status IN ('queued', 'running')
|
||||||
|
AND json_extract(payload, '$.week_start') = sqlc.arg(week_start)
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- name: ListTriedCandidates :many
|
||||||
|
-- The ids of the dinners with status quest_log planned on date, on a plan
|
||||||
|
-- of any status, that have no Rotation check for date yet.
|
||||||
|
SELECT DISTINCT dinners.id
|
||||||
|
FROM plan_days
|
||||||
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
|
WHERE plan_days.date = sqlc.arg(date)
|
||||||
|
AND dinners.status = 'quest_log'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM rotation_reviews
|
||||||
|
WHERE rotation_reviews.dinner_id = dinners.id
|
||||||
|
AND rotation_reviews.plan_date = plan_days.date
|
||||||
|
)
|
||||||
|
ORDER BY dinners.id;
|
||||||
|
|
||||||
|
-- name: MarkDinnerTried :execrows
|
||||||
|
-- Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
|
-- status is left as it is (0 rows).
|
||||||
|
UPDATE dinners
|
||||||
|
SET status = 'tried', updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'quest_log';
|
||||||
|
|
||||||
|
-- name: CreateRotationReview :exec
|
||||||
|
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?);
|
||||||
|
|
||||||
|
-- name: ListPendingRotationReviews :many
|
||||||
|
-- The pending Rotation checks whose dinner is still tried, oldest plan
|
||||||
|
-- date first, with their dinners.
|
||||||
|
SELECT rotation_reviews.plan_date, sqlc.embed(dinners)
|
||||||
|
FROM rotation_reviews
|
||||||
|
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
|
||||||
|
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
|
||||||
|
ORDER BY rotation_reviews.plan_date, rotation_reviews.id;
|
||||||
|
|
||||||
|
-- name: GetPendingRotationReview :one
|
||||||
|
-- The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||||
|
-- dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||||
|
-- most one. Returns sql.ErrNoRows when there is none.
|
||||||
|
SELECT * FROM rotation_reviews
|
||||||
|
WHERE dinner_id = ? AND status = 'pending';
|
||||||
|
|
||||||
|
-- name: CountRotationReviews :one
|
||||||
|
-- How many Rotation checks dinner_id has had, pending or decided.
|
||||||
|
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?;
|
||||||
|
|
||||||
|
-- name: DecideRotationReview :execrows
|
||||||
|
-- Records the household's decision on a pending Rotation check, with the
|
||||||
|
-- dinner's name for a drop and NULL otherwise; one already decided is left
|
||||||
|
-- as it is (0 rows).
|
||||||
|
UPDATE rotation_reviews
|
||||||
|
SET status = 'decided',
|
||||||
|
decision = sqlc.arg(decision),
|
||||||
|
name = sqlc.narg(name),
|
||||||
|
decided_at = sqlc.arg(now),
|
||||||
|
updated_by = sqlc.arg(updated_by),
|
||||||
|
updated_at = sqlc.arg(now)
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||||
|
|
||||||
|
-- name: MoveTriedDinner :execrows
|
||||||
|
-- Moves a dinner still tried to status (regular, archived or quest_log)
|
||||||
|
-- by a Rotation check decision, recording updated_by; its origin is kept.
|
||||||
|
-- A dinner no longer tried is left as it is (0 rows).
|
||||||
|
UPDATE dinners
|
||||||
|
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'tried';
|
||||||
@@ -86,12 +86,20 @@ FROM suggestions
|
|||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
||||||
|
|
||||||
-- name: ListDeclinedSuggestionNames :many
|
-- name: ListPassedNames :many
|
||||||
-- The name every declined suggestion was suggested under, oldest decision
|
-- Every name the household turned down, oldest decision first: the name
|
||||||
-- first; renaming its dinner does not change it.
|
-- each declined suggestion was suggested under, which renaming its dinner
|
||||||
SELECT name
|
-- does not change, and the name each dinner a Rotation check dropped
|
||||||
FROM suggestions
|
-- ("Not for us") had when dropped, which renaming it does not change.
|
||||||
WHERE status = 'declined'
|
SELECT name FROM (
|
||||||
|
SELECT name, decided_at, id
|
||||||
|
FROM suggestions
|
||||||
|
WHERE status = 'declined'
|
||||||
|
UNION ALL
|
||||||
|
SELECT name, decided_at, id
|
||||||
|
FROM rotation_reviews
|
||||||
|
WHERE decision = 'drop'
|
||||||
|
)
|
||||||
ORDER BY decided_at, id;
|
ORDER BY decided_at, id;
|
||||||
|
|
||||||
-- name: DecideSuggestion :execrows
|
-- name: DecideSuggestion :execrows
|
||||||
|
|||||||
+19
-18
@@ -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 = {
|
||||||
'/onboarding': true,
|
'/onboarding': true,
|
||||||
|
'/plan': true,
|
||||||
'/discover': true,
|
'/discover': true,
|
||||||
'/settings': true,
|
'/settings': true,
|
||||||
'/settings/ai': true,
|
'/settings/ai': true,
|
||||||
@@ -165,19 +166,19 @@ describe('App first-run redirect', () => {
|
|||||||
it('opens the routes once onboarding is finished', async () => {
|
it('opens the routes once onboarding is finished', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath(SIGNED_IN)
|
stubFetchByPath(SIGNED_IN)
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
screen.getByRole('button', { name: 'Retry' }).click()
|
screen.getByRole('button', { name: 'Retry' }).click()
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,12 +186,12 @@ describe('App bootstrap', () => {
|
|||||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
|
|
||||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||||
})
|
})
|
||||||
@@ -258,7 +259,7 @@ describe('App offline bootstrap', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/plan', 'Plan'],
|
['/quick', 'Quick dinners'],
|
||||||
['/dinner/01J9ZK', 'Dinner'],
|
['/dinner/01J9ZK', 'Dinner'],
|
||||||
])('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()
|
||||||
@@ -282,7 +283,7 @@ describe('App offline bootstrap', () => {
|
|||||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||||
)
|
)
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||||
onLine = false
|
onLine = false
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -290,7 +291,7 @@ describe('App offline bootstrap', () => {
|
|||||||
})
|
})
|
||||||
act(() => failMe())
|
act(() => failMe())
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
@@ -299,15 +300,15 @@ describe('App offline bootstrap', () => {
|
|||||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports the failure offline when this device never verified a session', async () => {
|
it('reports the failure offline when this device never verified a session', async () => {
|
||||||
goOffline()
|
goOffline()
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
@@ -316,12 +317,12 @@ describe('App offline bootstrap', () => {
|
|||||||
it('reports the failure offline after the session was refused', async () => {
|
it('reports the failure offline after the session was refused', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||||
const { location, unmount } = renderAt('/plan')
|
const { location, unmount } = renderAt('/quick')
|
||||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||||
unmount()
|
unmount()
|
||||||
|
|
||||||
goOffline()
|
goOffline()
|
||||||
renderAt('/plan')
|
renderAt('/quick')
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -329,13 +330,13 @@ describe('App offline bootstrap', () => {
|
|||||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
goOffline()
|
goOffline()
|
||||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
render(
|
render(
|
||||||
<InDataRouter path="/plan">
|
<InDataRouter path="/quick">
|
||||||
<App api={api} serviceWorker={undefined} />
|
<App api={api} serviceWorker={undefined} />
|
||||||
</InDataRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||||
|
|
||||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||||
@@ -350,6 +351,6 @@ describe('App offline bootstrap', () => {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
|||||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||||
|
import { PlanPage } from './plan/PlanPage.tsx'
|
||||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
@@ -36,6 +37,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>> = {
|
||||||
|
'/plan': <PlanPage />,
|
||||||
'/discover': <DiscoverPage />,
|
'/discover': <DiscoverPage />,
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
|
|||||||
@@ -225,4 +225,14 @@ describe('network status', () => {
|
|||||||
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
|
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
|
||||||
expect(api.network.offline()).toBe(false)
|
expect(api.network.offline()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('says whether a read is the service worker\'s cached copy', async () => {
|
||||||
|
const cached = jsonResponse(200, { id: '01J9ZR' })
|
||||||
|
cached.headers.set(cacheFallbackHeader, '1')
|
||||||
|
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
|
||||||
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
|
||||||
|
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true })
|
||||||
|
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+19
-1
@@ -43,11 +43,18 @@ export type ApiRequest = {
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApiRead is a GET's decoded response body, and whether the service worker
|
||||||
|
// answered it from its cache because the server could not be reached in time.
|
||||||
|
export type ApiRead<T> = { body: T; cached: boolean }
|
||||||
|
|
||||||
export type ApiClient = {
|
export type ApiClient = {
|
||||||
// apiFetch sends a same-origin JSON request to path, which must be under
|
// apiFetch sends a same-origin JSON request to path, which must be under
|
||||||
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
||||||
// ApiError.
|
// ApiError.
|
||||||
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
||||||
|
// apiRead is apiFetch's GET that also says whether the body is the service
|
||||||
|
// worker's cached copy rather than the server's answer.
|
||||||
|
apiRead<T>(path: string): Promise<ApiRead<T>>
|
||||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||||
loadMe(): Promise<Me>
|
loadMe(): Promise<Me>
|
||||||
// forbidden reports whether a request was refused because the signed-in
|
// forbidden reports whether a request was refused because the signed-in
|
||||||
@@ -114,6 +121,16 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||||
|
return (await exchange<T>(path, request)).body
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiRead<T>(path: string): Promise<ApiRead<T>> {
|
||||||
|
return exchange<T>(path, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends request to path and decodes the response, noting whether
|
||||||
|
// it is the service worker's cached copy.
|
||||||
|
async function exchange<T>(path: string, request: ApiRequest): Promise<ApiRead<T>> {
|
||||||
const method = request.method ?? 'GET'
|
const method = request.method ?? 'GET'
|
||||||
const url = apiURL(location.origin, method, path)
|
const url = apiURL(location.origin, method, path)
|
||||||
// fetch refuses a GET with a body by rejecting with a TypeError, the same
|
// fetch refuses a GET with a body by rejecting with a TypeError, the same
|
||||||
@@ -131,7 +148,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
|||||||
response = await send(method, url, request.body)
|
response = await send(method, url, request.body)
|
||||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||||
}
|
}
|
||||||
return decode<T>(method, path, response)
|
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||||
@@ -179,6 +196,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
|
apiRead,
|
||||||
loadMe,
|
loadMe,
|
||||||
forbidden: () => forbidden,
|
forbidden: () => forbidden,
|
||||||
subscribe(listener) {
|
subscribe(listener) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||||
export { ApiError } from './errors.ts'
|
export { ApiError } from './errors.ts'
|
||||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||||
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
|
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type QuestLogSheetProps = {
|
|||||||
|
|
||||||
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||||
// neither is known.
|
// neither is known.
|
||||||
function details(dinner: QuestLogDinner): string | null {
|
export function details(dinner: QuestLogDinner): string | null {
|
||||||
const parts = [
|
const parts = [
|
||||||
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||||
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
|
|||||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||||
|
|
||||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
||||||
// it: photo, name, difficulty and minutes.
|
// it: photo, name, difficulty and minutes, with the method a swap carries
|
||||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
// onto the plan.
|
||||||
|
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
|
||||||
|
|
||||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||||
@@ -127,8 +128,8 @@ export function useQuestLog() {
|
|||||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||||
return body.dinners.map((dinner) => {
|
return body.dinners.map((dinner) => {
|
||||||
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
|
||||||
return { id, name, photo_id, difficulty, minutes_total }
|
return { id, name, photo_id, difficulty, method, minutes_total }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -250,13 +251,14 @@ export function useDecide() {
|
|||||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||||
? dinners
|
? dinners
|
||||||
: // The deck does not say the minutes; the Quest log read after the
|
: // The deck does not say the method or minutes; the Quest log
|
||||||
// decision does.
|
// read after the decision does.
|
||||||
dinners.concat({
|
dinners.concat({
|
||||||
id: quest.dinner_id,
|
id: quest.dinner_id,
|
||||||
name: quest.name,
|
name: quest.name,
|
||||||
photo_id: quest.photo_id,
|
photo_id: quest.photo_id,
|
||||||
difficulty: quest.difficulty,
|
difficulty: quest.difficulty,
|
||||||
|
method: null,
|
||||||
minutes_total: null,
|
minutes_total: null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,589 @@
|
|||||||
|
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobilePage {
|
||||||
|
max-width: 440px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The grid fills the screen below the header: the main area's 32 px top and
|
||||||
|
bottom padding (AppShell) is taken back off the viewport height. */
|
||||||
|
.tabletPage .board {
|
||||||
|
min-height: calc(100dvh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: the board beside the 290 px right panel, which runs the full
|
||||||
|
height of the screen to its right edge over the main area's padding. */
|
||||||
|
.desktopPage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 290px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: -32px -36px -32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopPage .board {
|
||||||
|
padding: 32px 36px 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekNav {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The assistant: a 32 px grape "Q" avatar beside its note. */
|
||||||
|
.assistant {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 4px 18px 18px 18px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two columns of 86 px day tiles; the 8th cell is "+ Rebuild week". */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
min-height: 86px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole tile is the button that opens the swap sheet. */
|
||||||
|
.tileButton {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A Quest-log pick. */
|
||||||
|
.newTile {
|
||||||
|
background: var(--color-accent-2-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayKicker {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.newTile .dayKicker {
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dinner {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startBy {
|
||||||
|
display: block;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.newTile .startBy {
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The day being swapped, while the swap sheet is open: the page background
|
||||||
|
ringed in grape, its name muted. */
|
||||||
|
.swapping {
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapping .dayKicker {
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapping .dinner,
|
||||||
|
.swapping .startBy {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rebuildCell {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rebuild {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px dashed var(--color-neutral-400);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rebuild:hover:not(:disabled) {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rebuild:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonTile {
|
||||||
|
height: 86px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drafting {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
||||||
|
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
||||||
|
or focusing it shows the tooltip. */
|
||||||
|
.unavailable {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pushButton {
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable [aria-disabled='true'] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unavailable, it neither darkens nor presses. */
|
||||||
|
.unavailable .primary[aria-disabled='true']:hover,
|
||||||
|
.unavailable .primary[aria-disabled='true']:active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable .outline[aria-disabled='true']:hover,
|
||||||
|
.unavailable .outline[aria-disabled='true']:active {
|
||||||
|
background: transparent;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-text);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the plan manager's header the tooltip opens below, at the action's
|
||||||
|
right edge. */
|
||||||
|
.below .hint {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
bottom: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: none;
|
||||||
|
width: 240px;
|
||||||
|
max-width: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable:hover .hint,
|
||||||
|
.unavailable [aria-disabled='true']:focus + .hint {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The plan manager's header: kicker and title beside the week buttons, over
|
||||||
|
the sync status and the actions. */
|
||||||
|
.wideHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wideTitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabletPage .wideTitle {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wideHeader .kicker {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wideHeader .weekNav {
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thisWeek {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thisWeek:hover {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The status keeps to one line; the actions wrap under it when the row is
|
||||||
|
too narrow for both. */
|
||||||
|
.status {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.synced {
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The week: seven columns, each a day label over its card. A column is
|
||||||
|
never narrower than 112 px, which holds a dinner's words whole and the
|
||||||
|
tag beside the swap icon; where seven of those do not fit (tablet's
|
||||||
|
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
|
||||||
|
padding, taken back by the margin, keeps the swap icons' hit boxes and
|
||||||
|
focus rings inside the scroll area. */
|
||||||
|
.week {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(112px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: -8px;
|
||||||
|
padding: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayName {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayNumber {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet .card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A Side quest's card. */
|
||||||
|
.sideQuestCard {
|
||||||
|
background: var(--color-accent-2-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPhoto {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet .cardPhoto {
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardName {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet .cardName {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMinutes,
|
||||||
|
.cardStartBy {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardStartBy {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideQuestCard .cardMinutes,
|
||||||
|
.sideQuestCard .cardStartBy {
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The day a dragged dinner drops on: a dashed 2.5 px grape border on
|
||||||
|
grape-100 over the card, saying which dinner it replaces. */
|
||||||
|
.dropTarget {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2.5px dashed var(--color-accent);
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-accent-100);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropReplaces {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
|
||||||
|
keeps the 16 px footprint of its icon, reaching over the card's padding
|
||||||
|
and the tag. */
|
||||||
|
.cardFooter .swapButton {
|
||||||
|
margin: -14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter .cardTag {
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapButton > span {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonCard {
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,599 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||||
|
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useJob } from '../api/index.ts'
|
||||||
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
|
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||||
|
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||||
|
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||||
|
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||||
|
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 { Tag } from '../ui/Tag.tsx'
|
||||||
|
import {
|
||||||
|
DRAFT_JOB_KIND,
|
||||||
|
parseDraftResult,
|
||||||
|
planKey,
|
||||||
|
usePlan,
|
||||||
|
useStartDraft,
|
||||||
|
useSwapDay,
|
||||||
|
type DraftResult,
|
||||||
|
type Plan,
|
||||||
|
type PlanDay,
|
||||||
|
type PlanDinner,
|
||||||
|
type PlanStatus,
|
||||||
|
type Swap,
|
||||||
|
} from './api.ts'
|
||||||
|
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||||
|
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
|
||||||
|
import { SwapSheet } from './SwapSheet.tsx'
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
dayOfMonth,
|
||||||
|
defaultWeek,
|
||||||
|
localNow,
|
||||||
|
weekdayKicker,
|
||||||
|
weekdayName,
|
||||||
|
weekLabel,
|
||||||
|
weekStart as weekOf,
|
||||||
|
} from './week.ts'
|
||||||
|
import styles from './PlanPage.module.css'
|
||||||
|
|
||||||
|
// statusTags is the tag beside the week's title for each plan status.
|
||||||
|
const statusTags: Record<PlanStatus, { label: string; tone: 'grape' | 'teal' }> = {
|
||||||
|
draft: { label: 'Draft', tone: 'grape' },
|
||||||
|
synced: { label: 'Synced', tone: 'teal' },
|
||||||
|
changed: { label: 'Changes not pushed', tone: 'grape' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftFailures says why a draft_week job failed, by its failure code. Any
|
||||||
|
// other code is shown as it is.
|
||||||
|
const draftFailures: Record<string, string> = {
|
||||||
|
not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.',
|
||||||
|
ai_failed: "The AI assistant couldn't draft it.",
|
||||||
|
confirm_required: 'The plan was pushed to Calendar while it was drafting.',
|
||||||
|
plan_changed: 'The plan was edited while it was drafting.',
|
||||||
|
pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.',
|
||||||
|
internal_error: 'Something went wrong on the server.',
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUSH_HINT is why Push to Calendar is unavailable.
|
||||||
|
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
|
||||||
|
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
|
||||||
|
|
||||||
|
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||||
|
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||||
|
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||||
|
|
||||||
|
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||||
|
const syncStatuses: Record<PlanStatus, string> = {
|
||||||
|
draft: 'Not pushed to Google Calendar yet',
|
||||||
|
synced: 'Synced to Google Calendar',
|
||||||
|
changed: 'Changes not pushed to Google Calendar',
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||||
|
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||||
|
// between the method and the duration, never inside the duration.
|
||||||
|
export function duration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const rest = minutes % 60
|
||||||
|
if (hours === 0) return `${rest}\u00a0min`
|
||||||
|
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
|
||||||
|
// whichever of the two is known, or null when neither is.
|
||||||
|
export function cooking(dinner: PlanDinner): string | null {
|
||||||
|
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
|
||||||
|
(part): part is string => typeof part === 'string',
|
||||||
|
)
|
||||||
|
return parts.length === 0 ? null : parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ago is how long before now the instant at was: "just now", "2 min ago",
|
||||||
|
// "3 hr ago", "2 days ago".
|
||||||
|
export function ago(at: Date, now: Date): string {
|
||||||
|
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
|
||||||
|
if (minutes < 1) return 'just now'
|
||||||
|
if (minutes < 60) return `${minutes} min ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} hr ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||||
|
// confirm: it was pushed to Calendar.
|
||||||
|
function pushedToCalendar(status: PlanStatus): boolean {
|
||||||
|
return status !== 'draft'
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayTile is one day of the Draft board: a Regular on the surface, a
|
||||||
|
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
|
||||||
|
// start-by time in the household's zone. Tapping it opens the swap sheet;
|
||||||
|
// while that is open the tile is swapping: on the page background, ringed in
|
||||||
|
// grape, its name muted.
|
||||||
|
function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||||
|
const kicker = weekdayKicker(day.date)
|
||||||
|
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<li className={classes}>
|
||||||
|
<button type="button" className={styles.tileButton} onClick={onSwap}>
|
||||||
|
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
|
||||||
|
<span>
|
||||||
|
<span className={styles.dinner}>{day.dinner.name}</span>
|
||||||
|
{day.start_by !== null && (
|
||||||
|
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayColumn is one day of the plan manager's 7-column grid: its day label
|
||||||
|
// over a card with the dinner's washed photo, name, method and time, a
|
||||||
|
// crock-pot dinner's start-by time in the household's zone, and its Regular
|
||||||
|
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
|
||||||
|
// sheet; while that is open the card is swapping, ringed in grape. A dinner
|
||||||
|
// dragged over the day drops on it: the card is swapping, covered by a
|
||||||
|
// dashed grape "Drop to swap" saying which dinner it replaces.
|
||||||
|
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||||
|
const { dinner } = day
|
||||||
|
const time = cooking(dinner)
|
||||||
|
const target = useDropTarget(day.date) !== null
|
||||||
|
const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
|
||||||
|
<p className={styles.dayLabel} aria-hidden="true">
|
||||||
|
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
||||||
|
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
|
||||||
|
</p>
|
||||||
|
<div className={classes}>
|
||||||
|
{/* The card's name already says what the photo shows. */}
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
|
||||||
|
</span>
|
||||||
|
<p className={styles.cardName}>{dinner.name}</p>
|
||||||
|
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
|
||||||
|
{day.start_by !== null && (
|
||||||
|
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
|
||||||
|
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
|
||||||
|
<Icon icon={RefreshCw} size="inline" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{target && (
|
||||||
|
<div className={styles.dropTarget}>
|
||||||
|
<p className={styles.dropTitle}>Drop to swap</p>
|
||||||
|
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||||
|
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||||
|
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||||
|
const wide = layout !== 'mobile'
|
||||||
|
return (
|
||||||
|
<section aria-busy="true" aria-label={label} className={styles.loading}>
|
||||||
|
<div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
|
||||||
|
{Array.from({ length: 7 }, (_, i) => (
|
||||||
|
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{children !== undefined && <p className={styles.drafting}>{children}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekBoard is the Draft board of the week starting on weekStart. Drafting
|
||||||
|
// it — the empty state's "Draft this week" or the "+ Rebuild week" tile —
|
||||||
|
// starts the draft_week job and shows skeleton tiles until the job ends and
|
||||||
|
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||||
|
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||||
|
//
|
||||||
|
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
|
||||||
|
// unavailable Shopping list and Push to Calendar are actions in the header,
|
||||||
|
// over the plan's sync status and the 7-column grid.
|
||||||
|
function WeekBoard({
|
||||||
|
weekStart,
|
||||||
|
timezone,
|
||||||
|
layout,
|
||||||
|
onWeek,
|
||||||
|
}: {
|
||||||
|
weekStart: string
|
||||||
|
timezone: string
|
||||||
|
layout: Breakpoint
|
||||||
|
onWeek: (weekStart: string) => void
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const plan = usePlan(weekStart)
|
||||||
|
const startDraft = useStartDraft()
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
|
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const swap = useSwapDay(weekStart)
|
||||||
|
// The date of the day being swapped, while the swap sheet is open.
|
||||||
|
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||||
|
const label = weekLabel(weekStart)
|
||||||
|
|
||||||
|
// The draft's result, once the job has succeeded, or why it cannot be read.
|
||||||
|
const result = useMemo((): { draft: DraftResult | null; error: Error | null } => {
|
||||||
|
if (job.status !== 'succeeded') return { draft: null, error: null }
|
||||||
|
try {
|
||||||
|
return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null }
|
||||||
|
} catch (error) {
|
||||||
|
return { draft: null, error: error as Error }
|
||||||
|
}
|
||||||
|
}, [job.status, job.result, jobId, weekStart])
|
||||||
|
|
||||||
|
// Once the job ends the plan is read again: a succeeded draft is followed
|
||||||
|
// until that read lands; a failed one stays shown, and may have met a
|
||||||
|
// plan changed meanwhile. The Quest log is read again too, as after a
|
||||||
|
// swap: the draft may have planned some of it.
|
||||||
|
const [readJobId, setReadJobId] = useState<string | null>(null)
|
||||||
|
const ended = job.status === 'succeeded' || job.status === 'failed'
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ended) return
|
||||||
|
let active = true
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
|
||||||
|
if (active) setReadJobId(jobId)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [queryClient, ended, jobId, weekStart])
|
||||||
|
|
||||||
|
// The plan read shows once it is the one drafted, read from the server; no
|
||||||
|
// plan, another one, or a copy cached before the draft (a redraft keeps the
|
||||||
|
// plan's id) is an error rather than an empty or stale board. Its Retry
|
||||||
|
// reads the plan again, keeping the job: a later read from the server that
|
||||||
|
// is the draft still lands it.
|
||||||
|
const { draft } = result
|
||||||
|
const read = draft !== null && readJobId === jobId && plan.isSuccess
|
||||||
|
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
|
||||||
|
const unreadError =
|
||||||
|
read && !landed
|
||||||
|
? new Error(
|
||||||
|
plan.data === null
|
||||||
|
? `The week has no plan, not draft ${draft.plan_id}.`
|
||||||
|
: plan.data.id !== draft.plan_id
|
||||||
|
? `The week's plan is not draft ${draft.plan_id}.`
|
||||||
|
: `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const resultError = result.error ?? unreadError
|
||||||
|
useEffect(() => {
|
||||||
|
if (landed) setJobId(null)
|
||||||
|
}, [landed])
|
||||||
|
|
||||||
|
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
|
||||||
|
|
||||||
|
const { mutate } = startDraft
|
||||||
|
function start(confirm: boolean) {
|
||||||
|
setConfirming(false)
|
||||||
|
// A draft asked for again leaves the last one's failure, and a failed
|
||||||
|
// swap's, behind.
|
||||||
|
setJobId(null)
|
||||||
|
swap.dismiss()
|
||||||
|
mutate(
|
||||||
|
{ weekStart, confirm },
|
||||||
|
{
|
||||||
|
onSuccess: setJobId,
|
||||||
|
// A plan pushed to Calendar meanwhile needs confirm: read it again.
|
||||||
|
onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// rebuild drafts the week, asking first over a plan pushed to Calendar.
|
||||||
|
// It waits for a swap of the week still pending, which may yet fail: the
|
||||||
|
// Rebuild week tile is disabled meanwhile.
|
||||||
|
function rebuild() {
|
||||||
|
if (swap.pending) return
|
||||||
|
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||||
|
else start(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = swap.failed !== null ? (
|
||||||
|
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||||
|
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
) : startDraft.isError ? (
|
||||||
|
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||||
|
) : result.error !== null ? (
|
||||||
|
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
||||||
|
) : unreadError !== null ? (
|
||||||
|
<ErrorBanner onRetry={() => void plan.refetch()}>Couldn't read the draft. {unreadError.message}</ErrorBanner>
|
||||||
|
) : job.error === null ? null : job.status === 'failed' ? (
|
||||||
|
<ErrorBanner onRetry={rebuild}>
|
||||||
|
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
) : (
|
||||||
|
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapIn sends the swap chosen in the sheet and closes it.
|
||||||
|
function swapIn(chosen: Swap) {
|
||||||
|
setSwappingDate(null)
|
||||||
|
swap.mutate(chosen)
|
||||||
|
}
|
||||||
|
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||||
|
// nor dropped, while a swap of the week is pending.
|
||||||
|
function dropIn(chosen: Swap) {
|
||||||
|
swap.dismiss()
|
||||||
|
swapIn(chosen)
|
||||||
|
}
|
||||||
|
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||||
|
// a time, even across leaving the week and coming back: the plan the
|
||||||
|
// server answers it with must not overwrite another still pending.
|
||||||
|
function openSwap(date: string) {
|
||||||
|
if (swap.pending) return
|
||||||
|
swap.dismiss()
|
||||||
|
setSwappingDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body
|
||||||
|
if (plan.isError) {
|
||||||
|
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
||||||
|
} else if (drafting) {
|
||||||
|
body = (
|
||||||
|
<SkeletonTiles label="Drafting this week" layout={layout}>
|
||||||
|
Drafting your week…
|
||||||
|
</SkeletonTiles>
|
||||||
|
)
|
||||||
|
} else if (resultError !== null) {
|
||||||
|
// An unread draft shows no board: the plan read is not the draft.
|
||||||
|
body = null
|
||||||
|
} else if (plan.isPending) {
|
||||||
|
body = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||||
|
} else if (plan.data === null) {
|
||||||
|
body = (
|
||||||
|
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
|
||||||
|
I'll draft seven dinners from your Regulars and Quest log.
|
||||||
|
</EmptyState>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const { ai_note, days } = plan.data
|
||||||
|
const swappingDay = days.find((day) => day.date === swappingDate)
|
||||||
|
const sheet = swappingDay !== undefined && (
|
||||||
|
<SwapSheet
|
||||||
|
day={swappingDay}
|
||||||
|
planned={new Set(days.map((day) => day.dinner.id))}
|
||||||
|
onSwap={swapIn}
|
||||||
|
onClose={() => setSwappingDate(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
body =
|
||||||
|
layout !== 'mobile' ? (
|
||||||
|
<>
|
||||||
|
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
|
||||||
|
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||||
|
{days.map((day) => (
|
||||||
|
<DayColumn
|
||||||
|
key={day.date}
|
||||||
|
day={day}
|
||||||
|
timezone={timezone}
|
||||||
|
swapping={day.date === swappingDate}
|
||||||
|
onSwap={() => openSwap(day.date)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{sheet}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ai_note !== null && (
|
||||||
|
<div className={styles.assistant}>
|
||||||
|
<span className={styles.avatar} aria-hidden="true">
|
||||||
|
Q
|
||||||
|
</span>
|
||||||
|
<p className={styles.bubble}>{ai_note}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className={styles.grid} aria-label="Dinners this week">
|
||||||
|
{days.map((day) => (
|
||||||
|
<DayTile
|
||||||
|
key={day.date}
|
||||||
|
day={day}
|
||||||
|
timezone={timezone}
|
||||||
|
swapping={day.date === swappingDate}
|
||||||
|
onSwap={() => openSwap(day.date)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<li className={styles.rebuildCell}>
|
||||||
|
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||||
|
<Icon icon={Plus} size="inline" />
|
||||||
|
Rebuild week
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
||||||
|
Looks good — push to Calendar
|
||||||
|
</Unavailable>
|
||||||
|
{sheet}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = plan.data ? statusTags[plan.data.status] : null
|
||||||
|
const header =
|
||||||
|
layout !== 'mobile' ? (
|
||||||
|
<header className={styles.wideHeader}>
|
||||||
|
<div className={styles.titleBar}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<p className={styles.kicker}>{label.week} · Plan</p>
|
||||||
|
<h1 className={styles.wideTitle}>{label.dates}</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.weekNav}>
|
||||||
|
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
|
||||||
|
<Icon icon={ChevronLeft} size="inline" />
|
||||||
|
</IconButton>
|
||||||
|
{layout === 'desktop' && (
|
||||||
|
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
|
||||||
|
This week
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
|
||||||
|
<Icon icon={ChevronRight} size="inline" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actionBar}>
|
||||||
|
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||||
|
<Icon icon={ShoppingCart} size="inline" />
|
||||||
|
Shopping list
|
||||||
|
</Unavailable>
|
||||||
|
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
|
||||||
|
<Icon icon={RefreshCw} size="inline" />
|
||||||
|
Rebuild week
|
||||||
|
</Button>
|
||||||
|
<Unavailable hint={PUSH_HINT} below className={styles.action}>
|
||||||
|
<Icon icon={CalendarDays} size="inline" />
|
||||||
|
Push to Calendar
|
||||||
|
</Unavailable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<p className={styles.kicker}>{label.week}</p>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<h1 className={styles.title}>{label.dates}</h1>
|
||||||
|
{tag !== null && <Tag tone={tag.tone}>{tag.label}</Tag>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.weekNav}>
|
||||||
|
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
|
||||||
|
<Icon icon={ChevronLeft} size="inline" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
|
||||||
|
<Icon icon={ChevronRight} size="inline" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
{body}
|
||||||
|
{failure}
|
||||||
|
<Confirm
|
||||||
|
open={confirming}
|
||||||
|
title="Rebuild this week?"
|
||||||
|
confirmLabel="Rebuild week"
|
||||||
|
cancelLabel="Keep this plan"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => start(true)}
|
||||||
|
>
|
||||||
|
This week is already pushed to Calendar. The AI assistant drafts a new plan in its place.
|
||||||
|
</Confirm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus is the plan manager's status line: whether the plan is on
|
||||||
|
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
||||||
|
// minute by minute.
|
||||||
|
function SyncStatus({ plan }: { plan: Plan }) {
|
||||||
|
const { status, calendar_synced_at: syncedAt } = plan
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 60_000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
||||||
|
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||||
|
{syncStatuses[status]}
|
||||||
|
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
|
||||||
|
// shopping list): it stays focusable, so a tap or focus shows the tooltip
|
||||||
|
// saying why, above it or, at the top of the page, below.
|
||||||
|
function Unavailable({
|
||||||
|
hint,
|
||||||
|
variant = 'primary',
|
||||||
|
below = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
hint: string
|
||||||
|
variant?: ButtonVariant
|
||||||
|
below?: boolean
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const hintId = useId()
|
||||||
|
return (
|
||||||
|
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
className={[className, styles[variant]].filter(Boolean).join(' ')}
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-describedby={hintId}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
<span id={hintId} role="tooltip" className={styles.hint}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||||
|
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||||
|
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||||
|
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||||
|
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||||
|
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||||
|
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||||
|
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||||
|
export function PlanPage() {
|
||||||
|
const layout = useBreakpoint()
|
||||||
|
const settings = useHouseholdSettings()
|
||||||
|
const [week, setWeek] = useState<string | null>(null)
|
||||||
|
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||||
|
|
||||||
|
let content
|
||||||
|
if (settings.isError) {
|
||||||
|
content = (
|
||||||
|
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||||
|
)
|
||||||
|
} else if (week === null || !settings.data) {
|
||||||
|
content = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||||
|
} else {
|
||||||
|
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PlanDragProvider>
|
||||||
|
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||||
|
<div className={styles.board}>
|
||||||
|
{content}
|
||||||
|
{layout === 'tablet' && <QuestLogStrip />}
|
||||||
|
</div>
|
||||||
|
{layout === 'desktop' && <PlanPanel />}
|
||||||
|
{layout !== 'mobile' && <DragAnnouncer />}
|
||||||
|
</div>
|
||||||
|
</PlanDragProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */
|
||||||
|
.panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px 22px;
|
||||||
|
border-left: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
|
||||||
|
"dish photo" caption does not fit, so only its stripes show. */
|
||||||
|
.rowPhoto {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two columns of three 4 px dots. */
|
||||||
|
.grip {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4px 4px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet: the Quest log as chips on a surface card under the grid. */
|
||||||
|
.strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripHint {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 14px 5px 5px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As the row's photo, at 28 px. */
|
||||||
|
.chipPhoto {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row or a chip is a button dragged onto a day: by a pointer (no
|
||||||
|
scrolling or text selection under it) or with the keyboard. Its 2 px
|
||||||
|
border is clear until it is the ghost, inside its padding. */
|
||||||
|
.draggable {
|
||||||
|
margin: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.draggable {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.draggable {
|
||||||
|
padding: 3px 12px 3px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A dinner that cannot be picked up now: no grid to drop on, a swap
|
||||||
|
pending, or already this week. */
|
||||||
|
.draggable[aria-disabled='true'] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The row or chip being dragged: a dashed ghost at 55% opacity. */
|
||||||
|
.ghost {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--color-neutral-500);
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dragged copy floating below right of the pointer: lifted with the
|
||||||
|
large shadow and tilted -3deg. It lets the pointer through to the day
|
||||||
|
under it. */
|
||||||
|
.floating {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { Compass } from 'lucide-react'
|
||||||
|
import { useId, useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { useRegulars } from '../dinners/api.ts'
|
||||||
|
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||||
|
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { useDraggable, type Float } from './drag.tsx'
|
||||||
|
import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx'
|
||||||
|
import styles from './PlanPanel.module.css'
|
||||||
|
|
||||||
|
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
|
||||||
|
const DRAG_HINT = 'Drag a dinner onto a day to swap it in.'
|
||||||
|
|
||||||
|
// KEYS_HINT says how a dinner is swapped in with the keyboard.
|
||||||
|
const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.'
|
||||||
|
|
||||||
|
type List = ReturnType<typeof useQuestLog>
|
||||||
|
|
||||||
|
// ListBody is a list of the panel or the strip once it has loaded, or why it
|
||||||
|
// has not: its error with Retry, skeletons while loading, or that it is
|
||||||
|
// empty.
|
||||||
|
function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) {
|
||||||
|
if (list.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||||
|
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (list.isPending) {
|
||||||
|
return (
|
||||||
|
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className={styles.skeleton} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
|
||||||
|
return children(list.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grip is the 6-dot drag handle of a panel row.
|
||||||
|
function Grip() {
|
||||||
|
return (
|
||||||
|
<span className={styles.grip} aria-hidden="true">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<span key={i} className={styles.dot} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draggable is a dinner of the panel or the strip, dragged onto a day to
|
||||||
|
// swap it in: a button whose content is the row or the chip. While it is
|
||||||
|
// dragged it is a dashed ghost, and with a pointer a copy of it floats
|
||||||
|
// below right of the pointer, lifted and tilted.
|
||||||
|
function Draggable({
|
||||||
|
dinner,
|
||||||
|
fromQuestLog,
|
||||||
|
className,
|
||||||
|
keysHintId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
dinner: QuestLogDinner
|
||||||
|
fromQuestLog: boolean
|
||||||
|
className: string | undefined
|
||||||
|
keysHintId: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog })
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
|
||||||
|
aria-describedby={keysHintId}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating is the dragged copy of a row or a chip, drawn over the page.
|
||||||
|
function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) {
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="drag-float"
|
||||||
|
className={[className, styles.floating].filter(Boolean).join(' ')}
|
||||||
|
style={{ left: float.left, top: float.top, width: float.width }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeysHint is the keyboard's way to swap a dinner in, describing each
|
||||||
|
// draggable.
|
||||||
|
function KeysHint({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<p id={id} hidden>
|
||||||
|
{KEYS_HINT}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
|
||||||
|
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
|
||||||
|
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
|
||||||
|
// quests.
|
||||||
|
export function PlanPanel() {
|
||||||
|
const questLog = useQuestLog()
|
||||||
|
const regulars = useRegulars()
|
||||||
|
const [source, setSource] = useState<Source>('quest_log')
|
||||||
|
const lists = { quest_log: questLog, regulars }
|
||||||
|
const keysHintId = useId()
|
||||||
|
|
||||||
|
const label = (s: Source) => {
|
||||||
|
const dinners = lists[s].data
|
||||||
|
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside aria-label="Quest log and Regulars" className={styles.panel}>
|
||||||
|
<Segmented
|
||||||
|
label="Dinners to swap in"
|
||||||
|
options={[
|
||||||
|
{ value: 'quest_log', label: label('quest_log') },
|
||||||
|
{ value: 'regulars', label: label('regulars') },
|
||||||
|
]}
|
||||||
|
value={source}
|
||||||
|
onValueChange={setSource}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>{DRAG_HINT}</p>
|
||||||
|
<KeysHint id={keysHintId} />
|
||||||
|
<ListBody source={source} list={lists[source]}>
|
||||||
|
{(dinners) => (
|
||||||
|
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||||
|
{dinners.map((dinner) => (
|
||||||
|
<Draggable
|
||||||
|
key={dinner.id}
|
||||||
|
dinner={dinner}
|
||||||
|
fromQuestLog={source === 'quest_log'}
|
||||||
|
className={styles.row}
|
||||||
|
keysHintId={keysHintId}
|
||||||
|
>
|
||||||
|
{/* The row's name already says what the photo shows. */}
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.rowText}>
|
||||||
|
<span className={styles.rowName}>{dinner.name}</span>
|
||||||
|
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||||
|
</span>
|
||||||
|
<Grip />
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ListBody>
|
||||||
|
<Link to="/discover" className={styles.more}>
|
||||||
|
<Icon icon={Compass} size="inline" />
|
||||||
|
Swipe more side quests
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
|
||||||
|
// dinner a chip with its 28 px photo and name, dragged as the panel's rows
|
||||||
|
// are.
|
||||||
|
export function QuestLogStrip() {
|
||||||
|
const questLog = useQuestLog()
|
||||||
|
const keysHintId = useId()
|
||||||
|
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
|
||||||
|
return (
|
||||||
|
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
||||||
|
<div className={styles.stripHeader}>
|
||||||
|
<h2 className={styles.stripTitle}>{title}</h2>
|
||||||
|
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
||||||
|
</div>
|
||||||
|
<KeysHint id={keysHintId} />
|
||||||
|
<ListBody source="quest_log" list={questLog}>
|
||||||
|
{(dinners) => (
|
||||||
|
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
||||||
|
{dinners.map((dinner) => (
|
||||||
|
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
||||||
|
</span>
|
||||||
|
{dinner.name}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ListBody>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel
|
||||||
|
(mock 2d) as a bottom sheet. */
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replaces {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The 48 px washed photo; Photo applies the washed filter. */
|
||||||
|
.photo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A dinner already this week: shown, muted, and not a button. */
|
||||||
|
.already {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonRow {
|
||||||
|
height: 72px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
import { useRegulars } from '../dinners/api.ts'
|
||||||
|
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||||
|
import { details } from '../discover/QuestLogSheet.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { ListRow } from '../ui/ListRow.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { Tag } from '../ui/Tag.tsx'
|
||||||
|
import type { PlanDay, PlanDinner, Swap } from './api.ts'
|
||||||
|
import { weekdayName } from './week.ts'
|
||||||
|
import styles from './SwapSheet.module.css'
|
||||||
|
|
||||||
|
// Source is a list a dinner is swapped in from.
|
||||||
|
export type Source = 'quest_log' | 'regulars'
|
||||||
|
|
||||||
|
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||||
|
|
||||||
|
export const emptyLists: Record<Source, string> = {
|
||||||
|
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
|
||||||
|
regulars: 'You have no Regulars yet.',
|
||||||
|
}
|
||||||
|
|
||||||
|
// planDinner is a listed dinner as it is planned on a day.
|
||||||
|
export function planDinner(dinner: QuestLogDinner): PlanDinner {
|
||||||
|
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwapSheetProps = {
|
||||||
|
// The day being swapped.
|
||||||
|
day: PlanDay
|
||||||
|
// The ids of the dinners already on the week's plan.
|
||||||
|
planned: ReadonlySet<string>
|
||||||
|
onSwap: (swap: Swap) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
|
||||||
|
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
|
||||||
|
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
|
||||||
|
// dinner swaps it into the day; a dinner already this week can't be chosen.
|
||||||
|
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const questLog = useQuestLog()
|
||||||
|
const regulars = useRegulars()
|
||||||
|
const [source, setSource] = useState<Source>('quest_log')
|
||||||
|
const lists = { quest_log: questLog, regulars }
|
||||||
|
const list = lists[source]
|
||||||
|
|
||||||
|
const label = (s: Source) => {
|
||||||
|
const dinners = lists[s].data
|
||||||
|
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let body
|
||||||
|
if (list.isError) {
|
||||||
|
body = (
|
||||||
|
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||||
|
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
} else if (list.isPending) {
|
||||||
|
body = (
|
||||||
|
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className={styles.skeletonRow} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (list.data.length === 0) {
|
||||||
|
body = <p className={styles.empty}>{emptyLists[source]}</p>
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||||
|
{list.data.map((dinner: QuestLogDinner) => {
|
||||||
|
const already = planned.has(dinner.id)
|
||||||
|
return (
|
||||||
|
<li key={dinner.id}>
|
||||||
|
<ListRow
|
||||||
|
className={already ? styles.already : undefined}
|
||||||
|
leading={
|
||||||
|
// The row's name already says what the photo shows.
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
title={dinner.name}
|
||||||
|
meta={details(dinner)}
|
||||||
|
// A dinner already this week is no button: it can't be chosen.
|
||||||
|
onClick={
|
||||||
|
already
|
||||||
|
? undefined
|
||||||
|
: () =>
|
||||||
|
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
|
||||||
|
}
|
||||||
|
trailing={already && <Tag>Already this week</Tag>}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<div>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
Swap {weekdayName(day.date)}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
|
||||||
|
</div>
|
||||||
|
<Segmented
|
||||||
|
label="Swap in from"
|
||||||
|
options={[
|
||||||
|
{ value: 'quest_log', label: label('quest_log') },
|
||||||
|
{ value: 'regulars', label: label('regulars') },
|
||||||
|
]}
|
||||||
|
value={source}
|
||||||
|
onValueChange={setSource}
|
||||||
|
/>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
|
||||||
|
|
||||||
|
const WEEK = '2026-10-05'
|
||||||
|
const PLAN = `/api/v1/plans/${WEEK}`
|
||||||
|
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||||
|
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||||
|
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||||
|
|
||||||
|
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
|
||||||
|
// changed as given by index.
|
||||||
|
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'plan-1',
|
||||||
|
week_start: WEEK,
|
||||||
|
status: 'draft',
|
||||||
|
ai_note: null,
|
||||||
|
calendar_synced_at: null as string | null,
|
||||||
|
days: DAYS.map((date, i) => {
|
||||||
|
const change = changes[i]
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
|
||||||
|
is_new: change?.is_new ?? false,
|
||||||
|
start_by: change?.start_by ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deferred is a reply the test sends when it chooses.
|
||||||
|
function deferred() {
|
||||||
|
let send!: (response: Response) => void
|
||||||
|
const reply = new Promise<Response>((resolve) => (send = resolve))
|
||||||
|
return { reply, send }
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||||
|
// the Quest log; any other request fails the test.
|
||||||
|
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
|
||||||
|
const all: Record<string, () => Response | Promise<Response>> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
return Promise.resolve(respond())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSwaps renders the week's plan and swaps over a query client.
|
||||||
|
async function renderSwaps() {
|
||||||
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
const queryClient: QueryClient = createQueryClient()
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
|
||||||
|
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||||
|
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
|
||||||
|
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
// renderPlan renders the week's plan read over a query client.
|
||||||
|
async function renderPlan() {
|
||||||
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
const hook = renderHook(() => usePlan(WEEK), { wrapper })
|
||||||
|
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
||||||
|
return hook.result.current
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePlan', () => {
|
||||||
|
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
|
||||||
|
const body = planBody()
|
||||||
|
body.days[2]!.dinner.method = 'crockpot'
|
||||||
|
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
|
||||||
|
const plan = await renderPlan()
|
||||||
|
|
||||||
|
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
|
||||||
|
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||||
|
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||||
|
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||||
|
])('refuses a plan with %s', async (_, change, said) => {
|
||||||
|
const body = planBody()
|
||||||
|
change(body)
|
||||||
|
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
|
||||||
|
const plan = await renderPlan()
|
||||||
|
|
||||||
|
expect(plan.error?.message).toContain(said)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useSwapDay', () => {
|
||||||
|
// Friday's swap is sent first, then Monday's; the server answers Monday's
|
||||||
|
// with both swapped, Friday's with only Friday.
|
||||||
|
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['the later swap answered first', ['monday', 'friday'] as const],
|
||||||
|
['the earlier swap answered first', ['friday', 'monday'] as const],
|
||||||
|
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
|
||||||
|
const replies = { friday: deferred(), monday: deferred() }
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
|
||||||
|
[`PUT ${MONDAY}`]: () => replies.monday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
act(() => result.current.swap.mutate(FISH))
|
||||||
|
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||||
|
|
||||||
|
// Whichever reply settles, Monday stays swapped: Friday's plan, the
|
||||||
|
// earlier one, never lands over it.
|
||||||
|
for (const [settled, day] of order.entries()) {
|
||||||
|
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
|
||||||
|
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||||
|
}
|
||||||
|
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not take an earlier swap's failure for the week's", async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||||
|
})
|
||||||
|
const { result, plan } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
act(() => result.current.swap.mutate(FISH))
|
||||||
|
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||||
|
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.swap.pending).toBe(false))
|
||||||
|
expect(result.current.swap.failed).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a day a later plan read landed when the swap of it fails', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
// The read finds Friday already lemon orzo, starting by 2 pm.
|
||||||
|
let read = planBody()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
|
||||||
|
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
|
||||||
|
await act(() => result.current.plan.refetch())
|
||||||
|
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
|
||||||
|
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to dismiss a pending swap', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||||
|
const { result } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(result.current.swap.pending).toBe(true))
|
||||||
|
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
|
||||||
|
await act(async () => friday.send(jsonResponse(200, planBody())))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||||
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
|
|
||||||
|
export const PLANS_PATH = '/api/v1/plans'
|
||||||
|
|
||||||
|
// planPath is the plan of the week starting on Monday weekStart.
|
||||||
|
export function planPath(weekStart: string): string {
|
||||||
|
return `${PLANS_PATH}/${encodeURIComponent(weekStart)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// planKey is the query of the plan of the week starting on weekStart.
|
||||||
|
export function planKey(weekStart: string) {
|
||||||
|
return ['plans', weekStart] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
|
||||||
|
|
||||||
|
// PlanStatus is draft until Push to Calendar syncs the plan; an edit of a
|
||||||
|
// synced plan makes it changed.
|
||||||
|
export type PlanStatus = (typeof PLAN_STATUSES)[number]
|
||||||
|
|
||||||
|
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
|
||||||
|
// photo, minutes its minutes from prep to table and method how it is
|
||||||
|
// cooked, each when known.
|
||||||
|
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
|
||||||
|
|
||||||
|
// PlanDay is one day of a plan: its household-local date, the dinner
|
||||||
|
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
|
||||||
|
// dinner of known minutes, the instant it must start by.
|
||||||
|
export type PlanDay = {
|
||||||
|
date: string
|
||||||
|
dinner: PlanDinner
|
||||||
|
is_new: boolean
|
||||||
|
start_by: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
|
||||||
|
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
|
||||||
|
// Google Calendar, if ever. cached says the service worker answered from its
|
||||||
|
// cache: the server may have changed the plan since.
|
||||||
|
export type Plan = {
|
||||||
|
id: string
|
||||||
|
week_start: string
|
||||||
|
status: PlanStatus
|
||||||
|
ai_note: string | null
|
||||||
|
calendar_synced_at: Date | null
|
||||||
|
days: PlanDay[]
|
||||||
|
cached: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// DRAFT_JOB_KIND is the kind of the job drafting a week's plan.
|
||||||
|
export const DRAFT_JOB_KIND = 'draft_week'
|
||||||
|
|
||||||
|
// DraftResult is the draft_week job's result: the week drafted and its plan.
|
||||||
|
export type DraftResult = { week_start: string; plan_id: string }
|
||||||
|
|
||||||
|
function parseDay(where: string, body: unknown): PlanDay {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.date !== 'string' ||
|
||||||
|
!isRecord(body.dinner) ||
|
||||||
|
typeof body.dinner.id !== 'string' ||
|
||||||
|
typeof body.dinner.name !== 'string' ||
|
||||||
|
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
|
||||||
|
typeof body.is_new !== 'boolean' ||
|
||||||
|
(body.start_by !== null && typeof body.start_by !== 'string')
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: response has a day that is not a plan day`)
|
||||||
|
}
|
||||||
|
const dinner: PlanDinner = {
|
||||||
|
id: body.dinner.id,
|
||||||
|
name: body.dinner.name,
|
||||||
|
photo_id: photoId(where, body.dinner.photo),
|
||||||
|
minutes: body.dinner.minutes as number | null,
|
||||||
|
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
|
||||||
|
}
|
||||||
|
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||||
|
function syncedAt(where: string, value: unknown): Date | null {
|
||||||
|
if (value === null) return null
|
||||||
|
const at = typeof value === 'string' ? new Date(value) : null
|
||||||
|
if (at === null || Number.isNaN(at.getTime())) {
|
||||||
|
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.id !== 'string' ||
|
||||||
|
body.week_start !== weekStart ||
|
||||||
|
(body.ai_note !== null && typeof body.ai_note !== 'string') ||
|
||||||
|
!Array.isArray(body.days) ||
|
||||||
|
body.days.length !== 7
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: response is not the plan of the week of ${weekStart}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: body.id,
|
||||||
|
week_start: weekStart,
|
||||||
|
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
|
||||||
|
ai_note: body.ai_note,
|
||||||
|
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
|
||||||
|
days: body.days.map((day) => parseDay(where, day)),
|
||||||
|
cached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDraftResult checks a draft_week job's result, which useJob passes on
|
||||||
|
// unread, is the draft of the week starting on weekStart.
|
||||||
|
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
|
||||||
|
if (!isRecord(body) || body.week_start !== weekStart || typeof body.plan_id !== 'string') {
|
||||||
|
throw new Error(`${where}: result is not the draft of the week of ${weekStart}`)
|
||||||
|
}
|
||||||
|
return { week_start: weekStart, plan_id: body.plan_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePlan is the plan of the week starting on Monday weekStart (GET
|
||||||
|
// /api/v1/plans/{weekStart}), or null when the week has none (404).
|
||||||
|
export function usePlan(weekStart: string) {
|
||||||
|
const { apiRead } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: planKey(weekStart),
|
||||||
|
queryFn: async (): Promise<Plan | null> => {
|
||||||
|
const path = planPath(weekStart)
|
||||||
|
let read: ApiRead<unknown>
|
||||||
|
try {
|
||||||
|
read = await apiRead<unknown>(path)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return parsePlan(`GET ${path}`, weekStart, read.body, read.cached)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// useStartDraft asks the AI to draft the week starting on weekStart (POST
|
||||||
|
// /api/v1/plans/{weekStart}/draft) and resolves with the id of the
|
||||||
|
// draft_week job, which the server answers with the draft already running
|
||||||
|
// when there is one. Drafting over a plan pushed to Calendar (synced or
|
||||||
|
// changed) needs confirm.
|
||||||
|
export function useStartDraft() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ weekStart, confirm }: { weekStart: string; confirm: boolean }): Promise<string> => {
|
||||||
|
const path = `${planPath(weekStart)}/draft`
|
||||||
|
const body = await apiFetch<unknown>(path, { method: 'POST', body: confirm ? { confirm: true } : {} })
|
||||||
|
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${path}: response has no jobId`)
|
||||||
|
return body.jobId
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap is one day's swap: the day, and the dinner chosen for it with whether
|
||||||
|
// it was chosen from the Quest log.
|
||||||
|
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
|
||||||
|
|
||||||
|
// sameDay says whether two plan days are the same in every field.
|
||||||
|
function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||||
|
return (
|
||||||
|
a.date === b.date &&
|
||||||
|
a.dinner.id === b.dinner.id &&
|
||||||
|
a.dinner.name === b.dinner.name &&
|
||||||
|
a.dinner.photo_id === b.dinner.photo_id &&
|
||||||
|
a.dinner.minutes === b.dinner.minutes &&
|
||||||
|
a.dinner.method === b.dinner.method &&
|
||||||
|
a.is_new === b.is_new &&
|
||||||
|
a.start_by === b.start_by
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||||
|
// cache, where they outlive the Draft board that sent them.
|
||||||
|
function swapKey(weekStart: string) {
|
||||||
|
return ['plans', weekStart, 'swap'] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||||
|
// whether one is pending, and the failure of the latest, if it failed.
|
||||||
|
export type SwapDay = {
|
||||||
|
pending: boolean
|
||||||
|
failed: { swap: Swap; error: Error } | null
|
||||||
|
mutate: (chosen: Swap) => void
|
||||||
|
dismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// useSwapDay swaps the dinner of one day of the week starting on weekStart
|
||||||
|
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||||
|
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||||
|
// server answers with the plan as swapped, which then replaces it. A refused
|
||||||
|
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||||
|
// However the swap ends, the Quest log is read again: the server leaves a
|
||||||
|
// dinner on the plan out of it.
|
||||||
|
//
|
||||||
|
// The week's swaps live in the mutation cache, not in the component, so
|
||||||
|
// leaving the week and coming back keeps them: pending and failed are read
|
||||||
|
// from there. Only the latest swap of the week speaks for it: the plan an
|
||||||
|
// earlier one is answered with is not written over a later one, and its
|
||||||
|
// failure is not the week's. dismiss leaves settled swaps behind; it is
|
||||||
|
// refused while one is pending, so a later swap is always in the cache for
|
||||||
|
// an earlier, pending one to see.
|
||||||
|
export function useSwapDay(weekStart: string): SwapDay {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const key = planKey(weekStart)
|
||||||
|
const mutationKey = swapKey(weekStart)
|
||||||
|
const mutations = queryClient.getMutationCache()
|
||||||
|
// The week's swaps, in the order sent.
|
||||||
|
const swaps = useMutationState({
|
||||||
|
filters: { mutationKey },
|
||||||
|
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
|
||||||
|
})
|
||||||
|
const latest = swaps.at(-1)
|
||||||
|
|
||||||
|
function mutate(chosen: Swap) {
|
||||||
|
// isLatest says whether no swap of the week was sent after this one.
|
||||||
|
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||||
|
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||||
|
mutationKey,
|
||||||
|
// Kept until dismissed: a later swap must stay in the cache while an
|
||||||
|
// earlier one is pending.
|
||||||
|
gcTime: Infinity,
|
||||||
|
mutationFn: async ({ date, dinner }) => {
|
||||||
|
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
|
||||||
|
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
|
||||||
|
return parsePlan(`PUT ${path}`, weekStart, body, false)
|
||||||
|
},
|
||||||
|
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: key })
|
||||||
|
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||||
|
const previous = plan?.days.find((day) => day.date === date)
|
||||||
|
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||||
|
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||||
|
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||||
|
return { previous, swapped }
|
||||||
|
},
|
||||||
|
onSuccess: (plan) => {
|
||||||
|
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||||
|
},
|
||||||
|
// The day goes back only while it is still, field for field, the day
|
||||||
|
// this swap wrote: a later swap or plan read that replaced it is left
|
||||||
|
// alone.
|
||||||
|
onError: (_error, _swap, context) => {
|
||||||
|
if (context === undefined) return
|
||||||
|
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||||
|
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||||
|
})
|
||||||
|
// A failure is the mutation's state, which failed reports.
|
||||||
|
mutation.execute(chosen).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
const week = mutations.findAll({ mutationKey })
|
||||||
|
if (week.some((mutation) => mutation.state.status === 'pending')) {
|
||||||
|
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
|
||||||
|
}
|
||||||
|
for (const mutation of week) mutations.remove(mutation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||||
|
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||||
|
mutate,
|
||||||
|
dismiss,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* The drag's live region: read out, not shown. */
|
||||||
|
.announcer {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import type { PlanDay, PlanDinner, Swap } from './api.ts'
|
||||||
|
import { weekdayName } from './week.ts'
|
||||||
|
import styles from './drag.module.css'
|
||||||
|
|
||||||
|
// Pickup is a dinner picked up from the plan panel or the Quest-log strip,
|
||||||
|
// and whether it is a Quest-log dinner.
|
||||||
|
export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean }
|
||||||
|
|
||||||
|
// Board is the week's grid as a drag drops onto it: its days, whether a
|
||||||
|
// swap of the week is pending, and how a dinner dropped on a day is swapped
|
||||||
|
// in.
|
||||||
|
export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void }
|
||||||
|
|
||||||
|
// Drag is the dinner being dragged, by pointer or keyboard, and the date of
|
||||||
|
// the day it would replace, if any.
|
||||||
|
export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null }
|
||||||
|
|
||||||
|
type PlanDrag = {
|
||||||
|
drag: Drag | null
|
||||||
|
board: Board | null
|
||||||
|
setBoard: (board: Board | null) => void
|
||||||
|
message: string
|
||||||
|
// start picks a dinner up, unless it cannot be dropped: there is no grid,
|
||||||
|
// a swap of the week is pending, or the dinner is already this week. It
|
||||||
|
// says whether it did.
|
||||||
|
start: (pickup: Pickup, keyboard: boolean) => boolean
|
||||||
|
// over moves the drag onto the day of date, or off every day (null).
|
||||||
|
over: (date: string | null) => void
|
||||||
|
// step moves a keyboard drag to the previous (-1) or next (1) day.
|
||||||
|
step: (by: -1 | 1) => void
|
||||||
|
// end drops the dinner on the day of date, or cancels the drag when date
|
||||||
|
// is null. It says whether the dinner was dropped.
|
||||||
|
end: (date: string | null) => boolean
|
||||||
|
cancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanDragContext = createContext<PlanDrag | null>(null)
|
||||||
|
|
||||||
|
function usePlanDrag(): PlanDrag {
|
||||||
|
const context = useContext(PlanDragContext)
|
||||||
|
if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaces says which dinner dropping on day replaces: "Friday: replaces
|
||||||
|
// Pizza night."
|
||||||
|
function replaces(day: PlanDay): string {
|
||||||
|
return `${weekdayName(day.date)}: replaces ${day.dinner.name}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanDragProvider holds the drag of a dinner from the plan panel or the
|
||||||
|
// Quest-log strip onto a day of the week's grid, which the grid registers as
|
||||||
|
// the Board. Each step of a drag is announced to screen readers.
|
||||||
|
export function PlanDragProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [board, setBoard] = useState<Board | null>(null)
|
||||||
|
const [drag, setDrag] = useState<Drag | null>(null)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
// The drag as last set, read by handlers that run before a render: a blur
|
||||||
|
// the drop's focus move causes must not cancel the drag it just ended.
|
||||||
|
const current = useRef<Drag | null>(null)
|
||||||
|
|
||||||
|
const value = useMemo((): PlanDrag => {
|
||||||
|
function set(next: Drag | null) {
|
||||||
|
current.current = next
|
||||||
|
setDrag(next)
|
||||||
|
}
|
||||||
|
function dayOf(date: string | null): PlanDay | undefined {
|
||||||
|
return board?.days.find((day) => day.date === date)
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
const now = current.current
|
||||||
|
if (now === null) return
|
||||||
|
set(null)
|
||||||
|
setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
drag,
|
||||||
|
board,
|
||||||
|
setBoard,
|
||||||
|
message,
|
||||||
|
start(pickup, keyboard) {
|
||||||
|
const { name } = pickup.dinner
|
||||||
|
if (board === null) {
|
||||||
|
setMessage(`There is no plan this week to swap ${name} into.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (board.pending) {
|
||||||
|
setMessage(`A swap is still being saved. Pick ${name} up once it is done.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) {
|
||||||
|
setMessage(`${name} is already this week.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A keyboard drag starts on Monday; a pointer drag on no day.
|
||||||
|
const first = board.days[0]!
|
||||||
|
set({ pickup, keyboard, date: keyboard ? first.date : null })
|
||||||
|
setMessage(
|
||||||
|
keyboard
|
||||||
|
? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.`
|
||||||
|
: `Picked up ${name}.`,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
over(date) {
|
||||||
|
const now = current.current
|
||||||
|
if (now === null || now.date === date) return
|
||||||
|
set({ ...now, date })
|
||||||
|
},
|
||||||
|
step(by) {
|
||||||
|
const now = current.current
|
||||||
|
if (now === null || board === null) return
|
||||||
|
const at = board.days.findIndex((day) => day.date === now.date)
|
||||||
|
const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]!
|
||||||
|
set({ ...now, date: next.date })
|
||||||
|
setMessage(replaces(next))
|
||||||
|
},
|
||||||
|
end(date) {
|
||||||
|
const now = current.current
|
||||||
|
if (now === null) return false
|
||||||
|
const day = dayOf(date)
|
||||||
|
if (day === undefined || board === null) {
|
||||||
|
cancel()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set(null)
|
||||||
|
if (board.pending) {
|
||||||
|
setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog })
|
||||||
|
setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
cancel,
|
||||||
|
}
|
||||||
|
}, [board, drag, message])
|
||||||
|
|
||||||
|
return <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// DragAnnouncer is the polite live region a drag's steps are announced in.
|
||||||
|
export function DragAnnouncer() {
|
||||||
|
const { message } = usePlanDrag()
|
||||||
|
return (
|
||||||
|
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropBoard registers the week's grid as the Board while it is shown.
|
||||||
|
export function DropBoard({ days, pending, drop }: Board) {
|
||||||
|
const { setBoard } = usePlanDrag()
|
||||||
|
// drop is a new function on every render: the Board calls the latest.
|
||||||
|
const latestDrop = useRef(drop)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latestDrop.current = drop
|
||||||
|
})
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) })
|
||||||
|
return () => setBoard(null)
|
||||||
|
}, [setBoard, days, pending])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// useDropTarget is the drag over the day of date, if it is over that day.
|
||||||
|
export function useDropTarget(date: string): Drag | null {
|
||||||
|
const { drag } = usePlanDrag()
|
||||||
|
return drag !== null && drag.date === date ? drag : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROP_DATE is the attribute naming the date of a day a dinner drops on.
|
||||||
|
export const DROP_DATE = 'data-drop-date'
|
||||||
|
|
||||||
|
// dateAt is the date of the day under the point (x, y), or null when it is
|
||||||
|
// over no day.
|
||||||
|
function dateAt(x: number, y: number): string | null {
|
||||||
|
return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A press becomes a drag once the pointer has moved this far, so a tap is
|
||||||
|
// not one.
|
||||||
|
const DRAG_THRESHOLD = 4
|
||||||
|
|
||||||
|
// The dragged copy floats this far below and to the right of the pointer,
|
||||||
|
// as a drag image does, so it stays clear of the "Drop to swap" text
|
||||||
|
// centred in the day the pointer is over. Where it would leave the
|
||||||
|
// viewport it flips to the left of the pointer, or above it.
|
||||||
|
const FLOAT_OFFSET = 48
|
||||||
|
|
||||||
|
// Press is a pointer held on a draggable: its id, where it went down, the
|
||||||
|
// draggable's size, and whether it has become a drag.
|
||||||
|
type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean }
|
||||||
|
|
||||||
|
// Float is where the dragged copy of a draggable is drawn, and how wide.
|
||||||
|
export type Float = { left: number; top: number; width: number }
|
||||||
|
|
||||||
|
// floatAt is where the dragged copy of the held draggable is drawn for the
|
||||||
|
// pointer at (x, y): offset below right of it, flipped where that would
|
||||||
|
// leave the viewport.
|
||||||
|
function floatAt(held: Press, x: number, y: number): Float {
|
||||||
|
const right = x + FLOAT_OFFSET
|
||||||
|
const below = y + FLOAT_OFFSET
|
||||||
|
return {
|
||||||
|
left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right,
|
||||||
|
top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below,
|
||||||
|
width: held.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useDraggable makes a panel row or a strip chip a draggable dinner. With a
|
||||||
|
// pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it
|
||||||
|
// up, its copy floats with the pointer, and letting go over a day drops it
|
||||||
|
// there, anywhere else cancels. With the keyboard Enter or Space picks it
|
||||||
|
// up onto Monday, the arrow keys choose the day, Enter drops it and
|
||||||
|
// Escape, or leaving it, cancels; a drop moves focus to the day's swap
|
||||||
|
// button, as a Quest-log dinner leaves the Quest log once planned.
|
||||||
|
export function useDraggable(pickup: Pickup) {
|
||||||
|
const plan = usePlanDrag()
|
||||||
|
const [float, setFloat] = useState<Float | null>(null)
|
||||||
|
const press = useRef<Press | null>(null)
|
||||||
|
const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null
|
||||||
|
const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id))
|
||||||
|
|
||||||
|
// A draggable that goes mid-drag, as on crossing to mobile, cancels it;
|
||||||
|
// once the drag has ended, cancel does nothing.
|
||||||
|
const { cancel } = plan
|
||||||
|
const dragging = drag !== null
|
||||||
|
const latestCancel = useRef(cancel)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latestCancel.current = cancel
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
return () => latestCancel.current()
|
||||||
|
}, [dragging])
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
press.current = null
|
||||||
|
setFloat(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent<HTMLElement>) {
|
||||||
|
if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
press.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
dragging: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent<HTMLElement>) {
|
||||||
|
const held = press.current
|
||||||
|
if (held === null || held.pointerId !== event.pointerId) return
|
||||||
|
if (!held.dragging) {
|
||||||
|
if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return
|
||||||
|
if (!plan.start(pickup, false)) {
|
||||||
|
press.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
held.dragging = true
|
||||||
|
}
|
||||||
|
setFloat(floatAt(held, event.clientX, event.clientY))
|
||||||
|
plan.over(dateAt(event.clientX, event.clientY))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent<HTMLElement>) {
|
||||||
|
const held = press.current
|
||||||
|
if (held === null || held.pointerId !== event.pointerId) return
|
||||||
|
release()
|
||||||
|
if (held.dragging) plan.end(dateAt(event.clientX, event.clientY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cancel, or losing the capture before the release, cancels the drag.
|
||||||
|
// After a release the press is already over, so this ends only a drag
|
||||||
|
// whose capture was lost before it.
|
||||||
|
function onPointerCancel(event: PointerEvent<HTMLElement>) {
|
||||||
|
const held = press.current
|
||||||
|
if (held === null || held.pointerId !== event.pointerId) return
|
||||||
|
release()
|
||||||
|
if (held.dragging) plan.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||||
|
const button = event.currentTarget
|
||||||
|
if (drag === null) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
if (plan.drag === null) plan.start(pickup, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
release()
|
||||||
|
plan.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!drag.keyboard) return
|
||||||
|
// Space picked it up and does not drop it; it does not scroll the page.
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1)
|
||||||
|
else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1)
|
||||||
|
else if (event.key === 'Enter') drop(button.ownerDocument, drag.date)
|
||||||
|
else if (event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop ends a keyboard drag on the day of date and, once dropped, moves
|
||||||
|
// focus to that day's swap button.
|
||||||
|
function drop(document: Document, date: string | null) {
|
||||||
|
if (!plan.end(date)) return
|
||||||
|
const swapButton = document.querySelector<HTMLElement>(`[${DROP_DATE}="${date}"] button`)
|
||||||
|
if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`)
|
||||||
|
swapButton.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving a keyboard drag cancels it.
|
||||||
|
function onBlur() {
|
||||||
|
if (drag !== null && drag.keyboard) plan.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drag,
|
||||||
|
float,
|
||||||
|
props: {
|
||||||
|
'aria-disabled': disabled || undefined,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerCancel,
|
||||||
|
onLostPointerCapture: onPointerCancel,
|
||||||
|
onKeyDown,
|
||||||
|
onBlur,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||||
|
|
||||||
|
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||||
|
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||||
|
|
||||||
|
describe('week helpers', () => {
|
||||||
|
it('finds the Monday of a week and moves by days across months and years', () => {
|
||||||
|
expect(weekStart('2026-10-07')).toBe('2026-10-05')
|
||||||
|
expect(weekStart('2026-10-05')).toBe('2026-10-05')
|
||||||
|
expect(weekStart('2026-10-11')).toBe('2026-10-05')
|
||||||
|
expect(addDays('2026-10-05', 7)).toBe('2026-10-12')
|
||||||
|
expect(addDays('2026-10-05', -7)).toBe('2026-09-28')
|
||||||
|
expect(addDays('2026-12-28', 7)).toBe('2027-01-04')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('crosses a DST change without losing a day', () => {
|
||||||
|
expect(addDays('2026-10-26', 7)).toBe('2026-11-02')
|
||||||
|
expect(addDays('2027-03-08', 7)).toBe('2027-03-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels weeks as the API does, by ISO week', () => {
|
||||||
|
expect(weekLabel('2026-10-05')).toEqual({ week: 'Week 41', dates: 'Oct 5 – 11' })
|
||||||
|
expect(weekLabel('2026-09-28')).toEqual({ week: 'Week 40', dates: 'Sep 28 – Oct 4' })
|
||||||
|
expect(weekLabel('2026-12-28')).toEqual({ week: 'Week 53', dates: 'Dec 28 – Jan 3' })
|
||||||
|
expect(weekLabel('2027-01-04')).toEqual({ week: 'Week 1', dates: 'Jan 4 – 10' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the day of a date as a tile kicker', () => {
|
||||||
|
expect(weekdayKicker('2026-10-05')).toBe('MON')
|
||||||
|
expect(weekdayKicker('2026-10-11')).toBe('SUN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the day of a date in full', () => {
|
||||||
|
expect(weekdayName('2026-10-09')).toBe('Friday')
|
||||||
|
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the day of the month of a date', () => {
|
||||||
|
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||||
|
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||||
|
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a week start that is not a Monday, and a date that is not real', () => {
|
||||||
|
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
|
||||||
|
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
|
||||||
|
expect(() => weekStart('Oct 5')).toThrow('not a YYYY-MM-DD date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the household-local date and clock in its zone', () => {
|
||||||
|
// 03:30 UTC on Thursday is still Wednesday evening in New York.
|
||||||
|
expect(localNow('America/New_York', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-07', time: '23:30' })
|
||||||
|
expect(localNow('Asia/Tokyo', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-08', time: '12:30' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on this week until the plan_draft time, then on next week', () => {
|
||||||
|
expect(defaultWeek(SCHEDULE, new Date('2026-10-07T16:00:00Z'))).toBe('2026-10-05') // Wed noon
|
||||||
|
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T12:59:00Z'))).toBe('2026-10-05') // Fri 08:59
|
||||||
|
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T13:00:00Z'))).toBe('2026-10-12') // Fri 09:00
|
||||||
|
expect(defaultWeek(SCHEDULE, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-12') // Sat
|
||||||
|
expect(defaultWeek(SCHEDULE, new Date('2026-10-12T04:30:00Z'))).toBe('2026-10-12') // the next Mon, 00:30
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts a Sunday plan_draft day as the end of the Monday-start week', () => {
|
||||||
|
const sunday = { ...SCHEDULE, plan_draft_dow: 0, plan_draft_time: '18:00' }
|
||||||
|
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
|
||||||
|
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Household-local calendar helpers for the weekly plan, mirroring
|
||||||
|
// internal/household/week.go. Weeks are ISO weeks, Monday to Sunday. Dates
|
||||||
|
// are household-local YYYY-MM-DD strings; arithmetic is done on UTC
|
||||||
|
// midnights, which have no DST, so a date plus seven days is always the same
|
||||||
|
// weekday a week later.
|
||||||
|
|
||||||
|
const localDate = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const
|
||||||
|
|
||||||
|
// midnightUTC reads date as midnight UTC, or throws when it is not a real
|
||||||
|
// YYYY-MM-DD date.
|
||||||
|
function midnightUTC(date: string): Date {
|
||||||
|
const match = localDate.exec(date)
|
||||||
|
if (match === null) throw new Error(`not a YYYY-MM-DD date: "${date}"`)
|
||||||
|
const t = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
|
||||||
|
if (formatDate(t) !== date) throw new Error(`not a real date: "${date}"`)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(t: Date): string {
|
||||||
|
return t.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDays returns date moved by days, which may be negative.
|
||||||
|
export function addDays(date: string, days: number): string {
|
||||||
|
return formatDate(new Date(midnightUTC(date).getTime() + days * DAY_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekStart returns the Monday of the week containing date.
|
||||||
|
export function weekStart(date: string): string {
|
||||||
|
const sinceMonday = (midnightUTC(date).getUTCDay() + 6) % 7
|
||||||
|
return addDays(date, -sinceMonday)
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||||
|
export function weekdayKicker(date: string): string {
|
||||||
|
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayOfMonth is date's day of the month: 1 to 31.
|
||||||
|
export function dayOfMonth(date: string): number {
|
||||||
|
return midnightUTC(date).getUTCDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
|
||||||
|
|
||||||
|
// weekdayName is date's day in full: "Monday" to "Sunday".
|
||||||
|
export function weekdayName(date: string): string {
|
||||||
|
return weekday.format(midnightUTC(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||||
|
function monday(start: string): Date {
|
||||||
|
const t = midnightUTC(start)
|
||||||
|
if (t.getUTCDay() !== 1) throw new Error(`week start ${start} is not a Monday`)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// isoWeek is the ISO 8601 week number of the week starting on Monday start:
|
||||||
|
// the week holding that week's Thursday's year's first Thursday counts as 1.
|
||||||
|
function isoWeek(start: Date): number {
|
||||||
|
const thursday = new Date(start.getTime() + 3 * DAY_MS)
|
||||||
|
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
|
||||||
|
return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||||
|
|
||||||
|
// WeekLabel is the week starting on a Monday as the plan's header shows it:
|
||||||
|
// "Week 41" over "Oct 5 – 11", or "Sep 28 – Oct 4" when the week spans two
|
||||||
|
// months. Together they are the API's week_label.
|
||||||
|
export type WeekLabel = { week: string; dates: string }
|
||||||
|
|
||||||
|
// weekLabel labels the week starting on Monday start.
|
||||||
|
export function weekLabel(start: string): WeekLabel {
|
||||||
|
const first = monday(start)
|
||||||
|
const last = new Date(first.getTime() + 6 * DAY_MS)
|
||||||
|
const end = last.getUTCMonth() === first.getUTCMonth() ? String(last.getUTCDate()) : monthDay.format(last)
|
||||||
|
return { week: `Week ${isoWeek(first)}`, dates: `${monthDay.format(first)} – ${end}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// localNow is the household-local date (YYYY-MM-DD) and clock (24-hour
|
||||||
|
// HH:MM) at instant now in IANA zone.
|
||||||
|
export function localNow(zone: string, now: Date): { date: string; time: string } {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: zone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(now)
|
||||||
|
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value
|
||||||
|
return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultWeek is the Monday of the week the plan opens on: next week once
|
||||||
|
// this week's plan_draft time (day of the week counting from Sunday = 0,
|
||||||
|
// 24-hour HH:MM, both household-local) has come, otherwise this week.
|
||||||
|
export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number; plan_draft_time: string }, now: Date): string {
|
||||||
|
const today = localNow(schedule.timezone, now)
|
||||||
|
const thisWeek = weekStart(today.date)
|
||||||
|
const draftDay = addDays(thisWeek, (schedule.plan_draft_dow + 6) % 7)
|
||||||
|
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||||
|
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||||
|
}
|
||||||
@@ -19,3 +19,8 @@
|
|||||||
background: var(--color-accent-2-200);
|
background: var(--color-accent-2-200);
|
||||||
color: var(--color-accent-2-800);
|
color: var(--color-accent-2-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ describe('Tag', () => {
|
|||||||
render(<Tag tone="teal">Synced</Tag>)
|
render(<Tag tone="teal">Synced</Tag>)
|
||||||
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports the neutral tone', () => {
|
||||||
|
render(<Tag tone="neutral">Regular</Tag>)
|
||||||
|
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-2
@@ -1,13 +1,14 @@
|
|||||||
import type { ComponentPropsWithRef } from 'react'
|
import type { ComponentPropsWithRef } from 'react'
|
||||||
import styles from './Tag.module.css'
|
import styles from './Tag.module.css'
|
||||||
|
|
||||||
export type TagTone = 'grape' | 'teal'
|
export type TagTone = 'grape' | 'teal' | 'neutral'
|
||||||
|
|
||||||
export type TagProps = ComponentPropsWithRef<'span'> & {
|
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||||
tone?: TagTone
|
tone?: TagTone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
// Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
|
||||||
|
// interactive.
|
||||||
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||||
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||||
return <span className={classes} {...rest} />
|
return <span className={classes} {...rest} />
|
||||||
|
|||||||
Reference in New Issue
Block a user