Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 1f548c9f1d Refuse draft_week jobs on the generic jobs endpoint
CI / ci (push) Successful in 3m28s
CI / ci (pull_request) Successful in 3m10s
A kind can now be made service-only: Runner.Enqueue, and so
POST /api/v1/jobs, refuses it with 400 service_only_kind, and only its
typed Kind enqueues it. draft_week is service-only, so StartDraft and
its one-draft-in-flight-per-week rule is the only way to start a draft;
a confirmed draft_week posted to /api/v1/jobs can no longer run beside
the week's unconfirmed draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:57:53 -04:00
dev_agentandClaude Opus 5.5 45fcd71d27 Keep one draft in flight per week
CI / ci (push) Successful in 3m26s
CI / ci (pull_request) Successful in 3m10s
A confirmed draft asked for while the week's draft job was asked for
without confirm no longer enqueues a second job. With two job workers the
two drafts ran at once, and whichever stored second failed plan_changed,
so the household's confirmed draft could fail. StartDraft now refuses it
with ErrDraftRunning, 409 draft_running, and enqueues nothing; the
household asks again once the running draft finishes. Every other draft
joins the week's queued or running job, so a week has at most one.

The plan tests run the job runner with two workers, as cmd/server does,
and a confirmed draft during a running unconfirmed one is refused while
only the running job drafts.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:46:13 -04:00
dev_agentandClaude Opus 5.5 7e6e49f6b1 Guard plan drafts against swaps and keep confirm on single-flight
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m6s
Return the draft job id as jobId, as the plan API contract specifies.

Add a week_plans revision (migration 0013) that every swap and stored
draft raises. A draft_week job reads the revision before draft_week runs
and stores its week only while the plan still has it; a plan edited
while the AI ran is left as it is and the job fails plan_changed, so a
draft never erases the household's swap.

A confirmed draft now joins only a confirmed queued or running job, so a
job asked for without confirm can no longer fail it with
confirm_required after the plan was synced.

Test simultaneous drafts, a swap during a draft, a confirmed draft while
an unconfirmed one runs, impossible calendar dates and start_by across
the end of daylight saving time.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:32:51 -04:00
dev_agentandClaude Opus 5.5 75d6ad4e19 Add plan service and weekly plan API
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m1s
Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.

Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:18:09 -04:00
33 changed files with 79 additions and 2800 deletions
+1 -23
View File
@@ -101,7 +101,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `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, revision (raised by every swap and stored draft). |
| `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`), 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. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `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. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
@@ -364,28 +364,6 @@ automatic** (D-021).
`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, and any other dinner 409 `dinner_not_plannable`.
- 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.
- Everything else serves the embedded PWA with SPA fallback.
+5 -5
View File
@@ -15,8 +15,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PassedLimit is how many of the most recently passed names (Side quests
// passed and dinners a Rotation check dropped) the household context holds.
// PassedLimit is how many of the most recently passed Side quests the
// household context holds.
const PassedLimit = 60
// PlanWeeks is how many ISO weeks of plans, ending with the current week,
@@ -34,8 +34,8 @@ type HouseholdContext struct {
Regulars []RegularDinner
// QuestLog holds the Quest log dinner names, in name order.
QuestLog []string
// Passed holds the most recently passed names, oldest first: Side
// quests passed and dinners a Rotation check dropped ("Not for us").
// Passed holds the names of the most recently passed Side quests,
// oldest first.
Passed []string
// Plans are the planned dinners of the last PlanWeeks weeks, oldest
// 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)
}
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit)
if err != nil {
return h, fmt.Errorf("read passed Side quests: %w", err)
}
+2 -3
View File
@@ -30,9 +30,8 @@ type SideQuestsRequest struct {
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds every passed name — each passed Side quest and each
// dinner a Rotation check dropped — not only the recent ones Context
// holds.
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
Declined []string
}
+8 -14
View File
@@ -52,26 +52,20 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
return items, nil
}
const listRecentPassedNames = `-- name: ListRecentPassedNames :many
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
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'
)
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit)
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
if err != nil {
return nil, err
}
-1
View File
@@ -228,7 +228,6 @@ type RotationReview struct {
PlanDate string
Status string
Decision sql.NullString
Name sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
+6 -32
View File
@@ -25,8 +25,6 @@ type Querier interface {
CountDeck(ctx context.Context) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
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
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
@@ -36,16 +34,11 @@ type Querier interface {
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) 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
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) 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
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
@@ -94,10 +87,6 @@ type Querier interface {
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
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)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
@@ -132,19 +121,14 @@ type Querier interface {
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
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)
// 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)
@@ -152,14 +136,11 @@ type Querier interface {
// to_date, oldest first. Days with no dinner are left out.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, 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
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
@@ -169,9 +150,6 @@ type Querier interface {
// 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
@@ -179,10 +157,6 @@ type Querier interface {
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
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.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
-257
View File
@@ -1,257 +0,0 @@
// 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()
}
+8 -16
View File
@@ -348,25 +348,17 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
return items, nil
}
const listPassedNames = `-- name: ListPassedNames :many
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'
)
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id
`
// 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.
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPassedNames)
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
if err != nil {
return nil, err
}
-4
View File
@@ -5,10 +5,6 @@
// 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 (
-223
View File
@@ -1,223 +0,0 @@
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
}
-379
View File
@@ -1,379 +0,0 @@
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)
}
}
}
+1 -2
View File
@@ -32,7 +32,6 @@ const plansPath = "/api/v1/plans/"
type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
questLog string
}
@@ -90,7 +89,7 @@ func newPlansRouter(t *testing.T) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
-87
View File
@@ -1,87 +0,0 @@
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")
}
}
-147
View File
@@ -1,147 +0,0 @@
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)
}
}
+2 -5
View File
@@ -43,9 +43,8 @@ type RouteMounter interface {
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /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
// /api/v1/plans/{weekStart}/draft and PUT
// /api/v1/plans/{weekStart}/days/{date}; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
@@ -126,8 +125,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
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.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+4 -89
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
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,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,0013_week_plan_revision,0014_rotation_review_name", got)
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" {
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", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
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,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,0013_week_plan_revision,0014_rotation_review_name", got)
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" {
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", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -633,88 +633,3 @@ func TestMigrateSuggestionName(t *testing.T) {
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")
}
}
+9 -9
View File
@@ -43,7 +43,7 @@ type batchResult struct {
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
// Side quests given the household context, the deck and every passed
// Side quests given the household context, the deck and every declined
// 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
// 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,
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
// the deck's names and every declined name. A household with no Regulars
// fails with ReasonNoRegulars.
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
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 {
pending = append(pending, row.Dinner.Name)
}
declined, err := q.ListPassedNames(ctx)
declined, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err)
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
}
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
// 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
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
// repeats passed, or a Regular it cites renamed or archived. Declined
// names are read in the same transaction as the store, so a Pass committed
// before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
ids := []string{}
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 {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListPassedNames(ctx)
names, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
+1 -70
View File
@@ -21,7 +21,6 @@ import (
"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/plan"
"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/store"
@@ -647,7 +646,7 @@ func TestDeclineIsIdempotent(t *testing.T) {
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
t.Errorf("dinner status after refused accept = %s, want archived", got)
}
names, err := db.New(e.st.Reader).ListPassedNames(t.Context())
names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -913,74 +912,6 @@ 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
// job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish.
-43
View File
@@ -1,43 +0,0 @@
-- 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;
+6 -12
View File
@@ -1,17 +1,11 @@
-- Reads for the AI household context block (internal/ai/harness).
-- name: ListRecentPassedNames :many
-- The most recent names the household turned down, newest first, as
-- ListPassedNames has them.
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'
)
-- name: ListRecentDeclinedNames :many
-- The names the most recently declined suggestions were suggested under,
-- newest first; renaming their dinners does not change them.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
LIMIT ?;
-66
View File
@@ -1,66 +0,0 @@
-- 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';
+6 -14
View File
@@ -86,20 +86,12 @@ FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- name: ListPassedNames :many
-- 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.
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'
)
-- name: ListDeclinedSuggestionNames :many
-- The name every declined suggestion was suggested under, oldest decision
-- first; renaming its dinner does not change it.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows
+18 -19
View File
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -166,19 +165,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
@@ -186,12 +185,12 @@ describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -259,7 +258,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/quick', 'Quick dinners'],
['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -283,7 +282,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/quick')
renderAt('/plan')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -291,7 +290,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -300,15 +299,15 @@ describe('App offline bootstrap', () => {
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -317,12 +316,12 @@ describe('App offline bootstrap', () => {
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/quick')
const { location, unmount } = renderAt('/plan')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -330,13 +329,13 @@ describe('App offline bootstrap', () => {
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/quick">
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -351,6 +350,6 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
-2
View File
@@ -7,7 +7,6 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { PlanPage } from './plan/PlanPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
@@ -37,7 +36,6 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
-10
View File
@@ -225,14 +225,4 @@ describe('network status', () => {
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
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 })
})
})
+1 -19
View File
@@ -43,18 +43,11 @@ export type ApiRequest = {
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 = {
// 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
// ApiError.
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(): Promise<Me>
// forbidden reports whether a request was refused because the signed-in
@@ -121,16 +114,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
}
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 url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
@@ -148,7 +131,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
response = await send(method, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
return decode<T>(method, path, response)
}
// parseForbidden reads a 403. Any API error other than csrf means the
@@ -196,7 +179,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
return {
apiFetch,
apiRead,
loadMe,
forbidden: () => forbidden,
subscribe(listener) {
+1 -1
View File
@@ -1,5 +1,5 @@
export { ApiProvider, useApi } from './ApiContext.tsx'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
-242
View File
@@ -1,242 +0,0 @@
/* Plan · Draft board (mock 1f). */
.page {
box-sizing: border-box;
max-width: 440px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 8px;
}
.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;
flex-direction: column;
justify-content: space-between;
gap: 6px;
min-height: 86px;
padding: 12px;
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
}
/* A Quest-log pick. */
.newTile {
background: var(--color-accent-2-300);
}
.dayKicker {
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 {
margin: 0;
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1.15;
overflow-wrap: anywhere;
}
.startBy {
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);
}
.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 {
background: var(--color-neutral-100);
}
.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);
}
/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps
focus, so hovering, tapping or focusing it shows the tooltip. */
.push {
position: relative;
display: flex;
flex-direction: column;
}
.pushButton {
min-height: 50px;
}
.pushButton[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
/* Unavailable, it neither darkens nor presses. */
.push .pushButton[aria-disabled='true']:hover,
.push .pushButton[aria-disabled='true']:active {
background: var(--color-accent);
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;
}
.push:hover .hint,
.pushButton:focus + .hint {
visibility: visible;
}
-424
View File
@@ -1,424 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { PlanPage } from './PlanPage.tsx'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const DRAFT = `${THIS_WEEK}/draft`
const JOB = '/api/v1/jobs/j1'
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'Chicken stir-fry', 'Pizza night', 'Korean beef bowls', 'Sheet-pan salmon']
// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart}
// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are
// Quest-log picks, and the tikka masala must start by 1:30 pm New York time
// to be ready for 5:30 pm dinner.
function planBody(status: string, names = NAMES) {
return {
id: 'plan-1',
week_start: '2026-10-05',
week_label: 'Week 41 · Oct 5 – 11',
status,
ai_note: 'I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.',
calendar_synced_at: status === 'draft' ? null : '2026-10-04T15:00:00Z',
days: DAYS.map((date, i) => ({
date,
dinner: {
id: `d${i}`,
name: names[i],
photo: null,
minutes: i === 2 ? 240 : 30,
method: i === 2 ? 'crockpot' : 'stovetop',
origin: i === 2 || i === 5 ? 'side_quest' : 'regular',
},
is_new: i === 2 || i === 5,
start_by: i === 2 ? '2026-10-07T13:30:00-04:00' : null,
})),
updated_at: '2026-10-04T15:00:00Z',
}
}
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
// redraft keeps the plan's id.
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: planId } : null
return { id: 'j1', kind: 'draft_week', status, result, error }
}
type Responder = (init: RequestInit | undefined) => Response
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// posts lists the POSTs sent, as "path body".
function posts(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'POST')
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
// planReads counts the reads of this week's plan.
function planReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<PlanPage />
</QueryClientProvider>
</ApiProvider>,
)
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
function tile(kicker: string) {
return screen.getByText(kicker).closest('li')!
}
describe('PlanPage', () => {
it('renders mock 1f for a draft: title, Draft tag, the assistant note and the 2-column grid', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
const title = screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })
expect(declaredStyle(title).fontSize).toBe('26px')
expect(screen.getByText('Week 41')).toBeInTheDocument()
const tag = screen.getByText('Draft')
expect(declaredStyle(tag).background).toBe('var(--color-accent-100)')
expect(declaredStyle(tag).color).toBe('var(--color-accent-800)')
// The assistant: a 32 px grape "Q" avatar beside the plan's ai_note.
const avatar = screen.getByText('Q')
expect(declaredStyle(avatar).width).toBe('32px')
expect(declaredStyle(avatar).background).toBe('var(--color-accent)')
expect(screen.getByText('I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.')).toBeInTheDocument()
// Seven day tiles, Monday to Sunday, then the "+ Rebuild week" tile.
const grid = screen.getByRole('list', { name: 'Dinners this week' })
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
const items = within(grid).getAllByRole('listitem')
expect(items).toHaveLength(8)
expect(items.slice(0, 7).map((item) => item.firstChild!.textContent)).toEqual([
'MON',
'TUE',
'WED · NEW',
'THU',
'FRI',
'SAT · NEW',
'SUN',
])
expect(within(items[7]!).getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
const monday = tile('MON')
expect(declaredStyle(monday).minHeight).toBe('86px')
expect(declaredStyle(monday).borderRadius).toBe('22px')
expect(declaredStyle(monday).background).toBe('var(--color-surface)')
expect(declaredStyle(within(monday).getByText('Beef tacos')).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(within(monday).getByText('Beef tacos')).fontSize).toBe('15px')
})
it('highlights Quest-log picks in teal with the NEW kicker', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
await screen.findByText('WED · NEW')
const wednesday = tile('WED · NEW')
expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-300)')
expect(declaredStyle(within(wednesday).getByText('WED · NEW')).color).toBe('var(--color-accent-2-900)')
expect(declaredStyle(tile('SAT · NEW')).background).toBe('var(--color-accent-2-300)')
// A Regular stays on the surface, with no NEW.
expect(declaredStyle(tile('THU')).background).toBe('var(--color-surface)')
expect(screen.queryByText('THU · NEW')).not.toBeInTheDocument()
})
it('shows a crock-pot dinner its start-by time in the household zone, and no other dinner one', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Start by 1:30 pm')).toBeInTheDocument()
expect(within(tile('WED · NEW')).getByText('Start by 1:30 pm')).toBeInTheDocument()
expect(screen.getAllByText(/^Start by/)).toHaveLength(1)
})
it.each([
['synced', 'Synced', 'var(--color-accent-2-200)'],
['changed', 'Changes not pushed', 'var(--color-accent-100)'],
])('tags a %s plan "%s"', async (status, label, background) => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)) })
await renderPage()
const tag = await screen.findByText(label)
expect(declaredStyle(tag).background).toBe(background)
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
})
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.')
expect(declaredStyle(push).minHeight).toBe('50px')
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
})
it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
let plan = planBody('draft')
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toHaveAttribute('aria-busy', 'true')
expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7)
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
job = draftJob('succeeded')
expect(await screen.findByText('Lasagna', {}, { timeout: 3000 })).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
})
it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' })
expect(dialog).toHaveTextContent('already pushed to Calendar')
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(posts(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' }))
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
})
it('offers "Draft this week" with no plan, and shows skeleton tiles while it drafts', async () => {
let plan: unknown = null
let job = draftJob('queued')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Draft this week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7)
expect(screen.getByText('Drafting your week…')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
plan = planBody('draft')
job = draftJob('succeeded')
expect(await screen.findByText('Beef tacos', {}, { timeout: 3000 })).toBeInTheDocument()
expect(screen.getByText('Draft')).toBeInTheDocument()
})
it('shows a failed draft with Retry and makes up no plan', async () => {
let job = draftJob('failed', 'ai_failed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' }))
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent("Couldn't draft this week. The AI assistant couldn't draft it.")
expect(screen.getByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
job = draftJob('running')
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`, `${DRAFT} {}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
['the week has no plan', null, 'The week has no plan, not draft plan-1.'],
['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."],
])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => {
let plan: unknown = null
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
plan = landed
job = draftJob('succeeded')
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
// Retry reads the plan again rather than drafting again: the read is
// still not the draft.
let reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The next read is the draft, from the server: it lands.
plan = planBody('draft')
reads = planReads(fetchMock)
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(reads + 1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
})
it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => {
let plan = planBody('draft')
let cached = false
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
const response = jsonResponse(200, plan)
if (cached) response.headers.set(cacheFallbackHeader, '1')
return response
},
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
// The draft succeeds, but the server can't be reached: the service
// worker answers with the plan cached before the rebuild, same id.
cached = true
job = draftJob('succeeded')
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
// Retry reads the plan again and drafts nothing: still the cache, so the
// draft stays unread.
const reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The server is back: the next read of the plan is the draft.
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
cached = false
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
})
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
})
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByText('Week 42')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
it("opens on next week once this week's plan_draft time has come", async () => {
vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York
stubServer({ [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found') })
await renderPage()
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
})
it("reports a plan that can't be read, with Retry", async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(500, 'internal') })
await renderPage()
expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message")
})
})
-293
View File
@@ -1,293 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { useEffect, useId, useMemo, useState } from 'react'
import { useJob } from '../api/index.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { Button } 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 { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts'
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } 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.',
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.'
// 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.
function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
const kicker = weekdayKicker(day.date)
return (
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}>
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p>
<div>
<p className={styles.dinner}>{day.dinner.name}</p>
{day.start_by !== null && (
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
)}
</div>
</li>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it.
function SkeletonTiles({ label, children }: { label: string; children?: string }) {
return (
<section aria-busy="true" aria-label={label} className={styles.loading}>
<div className={styles.grid}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-tile" className={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.
function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; 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 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.
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: 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 behind.
setJobId(null)
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.
function rebuild() {
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
else start(false)
}
const failure = 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>
)
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">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" />
} 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
body = (
<>
{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} />
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<PushToCalendar />
</>
)
}
const tag = plan.data ? statusTags[plan.data.status] : null
return (
<>
<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>
{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>
</>
)
}
// PushToCalendar is "Looks good — push to Calendar", unavailable until
// Google Calendar can be connected: it stays focusable, so a tap or focus
// shows the tooltip saying why.
function PushToCalendar() {
const hintId = useId()
return (
<div className={styles.push}>
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}>
Looks good — push to Calendar
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{PUSH_HINT}
</span>
</div>
)
}
// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the
// previous / next week buttons. It opens on next week once this week's
// plan_draft time has come in the household's zone, otherwise on this week.
export function PlanPage() {
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" />
} else {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} onWeek={setWeek} />
}
return <div className={styles.page}>{content}</div>
}
-132
View File
@@ -1,132 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/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]
// 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: { id: string; name: string }
is_new: boolean
start_by: string | null
}
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
// Sunday, and the AI's note on the draft, if any. 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
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' ||
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`)
}
return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by }
}
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,
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
},
})
}
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { addDays, defaultWeek, localNow, weekdayKicker, 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('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
})
})
-98
View File
@@ -1,98 +0,0 @@
// 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()]!
}
// 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
}