Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 b3b342ef55 Revalidate the draft pool on save, refuse duplicate swaps, refresh the Quest log after a draft
CI / ci (push) Successful in 3m18s
CI / ci (pull_request) Successful in 3m19s
Feature 14 integration fix (Maestro 857e4c33-e250-46ec-b5ea-7dd4f3b719d9).

- F14-01: storeDraft re-reads the draft pool (ListDraftPool, the same
  eligibility rule draft_week was given) inside the save transaction. A
  day whose dinner was archived, moved to tried, or planned on another
  current or later week while draft_week ran fails the job with the new
  retryable reason pool_changed and stores nothing.
- F14-02: Swap refuses a dinner already on another day of the week inside
  the write transaction; the API answers 409 dinner_already_planned.
- F14-03: a finished draft invalidates the Quest log query, as the swap
  path does, so the tablet strip and desktop panel drop dinners it planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:13:22 -04:00
orchestrator-agent 955613941f Drag-and-drop and keyboard swapping (Feature 14) (#120)
CI / ci (pull_request) Successful in 3m24s
CI / ci (push) Successful in 3m28s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 15:45:11 -04:00
orchestrator-agent dba4b93280 Tablet and desktop plan manager (2c, 2d) (#119)
CI / ci (push) Successful in 3m18s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 14:22:48 -04:00
orchestrator-agent 3aef21922b Mobile swap sheet (Feature 14) (#118)
CI / ci (push) Successful in 3m13s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 13:28:03 -04:00
orchestrator-agent 9b9421bb58 Mobile draft board (1f) (#117)
CI / ci (push) Successful in 3m21s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 12:42:54 -04:00
orchestrator-agent 0a47b26af6 Rotation check: tried dinners and keep/drop decisions (#116)
CI / ci (push) Failing after 5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 07:39:22 -04:00
orchestrator-agent 2ed5f69783 Plan service and weekly plan API (#114)
CI / ci (push) Successful in 3m10s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 07:04:50 -04:00
48 changed files with 5974 additions and 109 deletions
+31 -3
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`), decided_at, updated_by. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_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. |
@@ -357,13 +357,41 @@ automatic** (D-021).
left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. A pool under seven
left as it is and the job fails `plan_changed`. The pool is read again
in the same transaction that stores the draft; if a dinner the draft
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
stored on another current or later week's plan, as by a draft of
another week), nothing is stored and the job fails `pool_changed`,
which drafting again resolves. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, and any other dinner 409 `dinner_not_plannable`.
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
`rotation_check` schedule calls for the household-local day just past,
moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
a dinner already checked for that date is left as it is, so it is
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
pending checks whose dinner is still tried, oldest `plan_date` first,
each `{dinner: {id, name, photo}, plan_date}`. `POST
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
("We didn't make it") returns it to the Quest log; its origin is kept.
The check is closed with `decided_at` and the session's `updated_by`,
and the answer is `{dinner, plan_date, decision, dinner_status,
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
an unknown dinner or a dinner never checked 404 `not_found`, a dinner
whose checks are all decided (including a second decision) 409
`no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
Keep is the only path from tried to Regular. A drop records the
dinner's name on the check (`rotation_reviews.name`), and that name
joins the passed names (`ListPassedNames`) the Side quest dedupe and
household context use, so it is never suggested again, even after the
dinner is renamed.
- `/health` — liveness.
- 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 Side quests the
// household context holds.
// PassedLimit is how many of the most recently passed names (Side quests
// passed and dinners a Rotation check dropped) 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 names of the most recently passed Side quests,
// oldest first.
// Passed holds the most recently passed names, oldest first: Side
// quests passed and dinners a Rotation check dropped ("Not for us").
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.ListRecentDeclinedNames(ctx, PassedLimit)
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
if err != nil {
return h, fmt.Errorf("read passed Side quests: %w", err)
}
+3 -2
View File
@@ -30,8 +30,9 @@ type SideQuestsRequest struct {
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
// Declined holds every passed name — each passed Side quest and each
// dinner a Rotation check dropped — not only the recent ones Context
// holds.
Declined []string
}
+14 -8
View File
@@ -52,20 +52,26 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
return items, nil
}
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
const listRecentPassedNames = `-- name: ListRecentPassedNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
ORDER BY decided_at DESC, id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// 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)
// 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)
if err != nil {
return nil, err
}
+1
View File
@@ -228,6 +228,7 @@ type RotationReview struct {
PlanDate string
Status string
Decision sql.NullString
Name sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
+32 -6
View File
@@ -25,6 +25,8 @@ 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
@@ -34,11 +36,16 @@ 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)
@@ -87,6 +94,10 @@ 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.
@@ -121,14 +132,19 @@ 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)
@@ -136,11 +152,14 @@ 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 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)
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
ListRecentPassedNames(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)
@@ -150,6 +169,9 @@ 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
@@ -157,6 +179,10 @@ 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
@@ -0,0 +1,257 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: rotation_reviews.sql
package db
import (
"context"
"database/sql"
)
const countRotationReviews = `-- name: CountRotationReviews :one
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
`
// How many Rotation checks dinner_id has had, pending or decided.
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRotationReview = `-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?)
`
type CreateRotationReviewParams struct {
ID string
DinnerID string
PlanDate string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error {
_, err := q.db.ExecContext(ctx, createRotationReview,
arg.ID,
arg.DinnerID,
arg.PlanDate,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const decideRotationReview = `-- name: DecideRotationReview :execrows
UPDATE rotation_reviews
SET status = 'decided',
decision = ?1,
name = ?2,
decided_at = ?3,
updated_by = ?4,
updated_at = ?3
WHERE id = ?5 AND status = 'pending'
`
type DecideRotationReviewParams struct {
Decision sql.NullString
Name sql.NullString
Now sql.NullString
UpdatedBy sql.NullString
ID string
}
// Records the household's decision on a pending Rotation check, with the
// dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideRotationReview,
arg.Decision,
arg.Name,
arg.Now,
arg.UpdatedBy,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getPendingRotationReview = `-- name: GetPendingRotationReview :one
SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending'
`
// The pending Rotation check of dinner_id. MarkTried makes one only for a
// dinner in the Quest log, which it moves to tried, so a dinner has at
// most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) {
row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID)
var i RotationReview
err := row.Scan(
&i.ID,
&i.DinnerID,
&i.PlanDate,
&i.Status,
&i.Decision,
&i.Name,
&i.DecidedAt,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many
SELECT rotation_reviews.plan_date, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
ORDER BY rotation_reviews.plan_date, rotation_reviews.id
`
type ListPendingRotationReviewsRow struct {
PlanDate string
Dinner Dinner
}
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingRotationReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingRotationReviewsRow
for rows.Next() {
var i ListPendingRotationReviewsRow
if err := rows.Scan(
&i.PlanDate,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTriedCandidates = `-- name: ListTriedCandidates :many
SELECT DISTINCT dinners.id
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.date = ?1
AND dinners.status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM rotation_reviews
WHERE rotation_reviews.dinner_id = dinners.id
AND rotation_reviews.plan_date = plan_days.date
)
ORDER BY dinners.id
`
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listTriedCandidates, date)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markDinnerTried = `-- name: MarkDinnerTried :execrows
UPDATE dinners
SET status = 'tried', updated_at = ?
WHERE id = ? AND status = 'quest_log'
`
type MarkDinnerTriedParams struct {
UpdatedAt string
ID string
}
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveTriedDinner = `-- name: MoveTriedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'tried'
`
type MoveTriedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Moves a dinner still tried to status (regular, archived or quest_log)
// by a Rotation check decision, recording updated_by; its origin is kept.
// A dinner no longer tried is left as it is (0 rows).
func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveTriedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+16 -8
View File
@@ -348,17 +348,25 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
return items, nil
}
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
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'
)
ORDER BY decided_at, id
`
// 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)
// 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)
if err != nil {
return nil, err
}
+23 -6
View File
@@ -36,9 +36,9 @@ func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran. A pool under seven dinners fails with
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
// week is made up instead.
// draft_week ran and every dinner it planned is still in the pool. A pool
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
// failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
@@ -68,7 +68,7 @@ func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult)
for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
}
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, base, week)
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
if err != nil {
return draftResult{}, err
}
@@ -142,11 +142,28 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
// draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String())
+25 -4
View File
@@ -5,6 +5,10 @@
// 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 (
@@ -49,10 +53,15 @@ const (
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit.
ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
)
// Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged}
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext).
@@ -67,6 +76,10 @@ var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
@@ -333,9 +346,10 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), and the dinner must be
// a Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped.
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart)
if err != nil {
@@ -365,6 +379,13 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status}
}
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true},
+81 -7
View File
@@ -174,9 +174,15 @@ func (e *env) reply(note string, names ...string) {
// weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name]))
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
@@ -398,15 +404,16 @@ func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
}
}
// Swapped to Beef Chili, Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil {
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
t.Fatal(err)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Beef Chili start_by = %s, %v; want 13:30 EST", b, err)
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
}
}
@@ -466,6 +473,70 @@ func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
}
}
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -633,7 +704,8 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
<-started
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil {
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err)
}
close(release)
@@ -646,7 +718,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Sheet Pan Salmon"
want[2] = "Fish Tacos"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
}
@@ -822,7 +894,8 @@ func TestSwapStatusTransitions(t *testing.T) {
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, tc.from)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
if err != nil {
t.Fatal(err)
}
@@ -852,6 +925,7 @@ func TestSwapRefusals(t *testing.T) {
}{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
+223
View File
@@ -0,0 +1,223 @@
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Rotation check decisions (the rotation_reviews.decision column), the
// household's answer to "Keep it in rotation?" (D-021).
const (
// DecisionKeep makes the tried dinner a Regular.
DecisionKeep = "keep"
// DecisionDrop ("Not for us") archives it; its name stays among the
// passed names, so it is never suggested again.
DecisionDrop = "drop"
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
DecisionNotMade = "not_made"
)
// decisionStatus is the status each decision moves a tried dinner to. Keep
// is the only way a tried dinner becomes a Regular: nothing automatic
// promotes one (D-021).
var decisionStatus = map[string]string{
DecisionKeep: dinners.StatusRegular,
DecisionDrop: dinners.StatusArchived,
DecisionNotMade: dinners.StatusQuestLog,
}
// ErrReviewNotFound is a decision on a dinner that has never had a
// Rotation check: it was never tried.
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
// all decided, such as a second decision.
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
// is no longer tried, such as one archived meanwhile.
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
// InvalidDecisionError is a decision that is not keep, drop or not_made.
type InvalidDecisionError struct {
Value string
}
func (e *InvalidDecisionError) Error() string {
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
}
// ReviewDinner is the dinner of a Rotation check.
type ReviewDinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
}
// Review is a pending Rotation check: the dinner tried and the plan date
// it was planned on.
type Review struct {
Dinner ReviewDinner `json:"dinner"`
// PlanDate is household-local, YYYY-MM-DD.
PlanDate string `json:"plan_date"`
}
// Decided is a Rotation check as decided.
type Decided struct {
Dinner ReviewDinner `json:"dinner"`
PlanDate string `json:"plan_date"`
Decision string `json:"decision"`
// DinnerStatus is the status the decision moved the dinner to.
DinnerStatus string `json:"dinner_status"`
DecidedAt string `json:"decided_at"`
UpdatedBy string `json:"updated_by"`
}
// MarkTried moves every dinner in the Quest log planned on date, the
// household-local day just past, on a plan of any status, to tried and
// opens a pending Rotation check of it for date. It is idempotent: a
// dinner already checked for date is left as it is, so a later run never
// moves one returned to the Quest log ("We didn't make it") again. It
// returns how many dinners it moved.
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
var n int
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
ids, err := q.ListTriedCandidates(ctx, date.String())
if err != nil {
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
}
now := store.Now().String()
for _, id := range ids {
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
}
if moved != 1 {
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
}
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
}
n = len(ids)
return nil
})
if err != nil {
return 0, err
}
return n, nil
}
// PendingReviews returns the pending Rotation checks, oldest plan date
// first.
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
if err != nil {
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
}
out := make([]Review, len(rows))
for i, r := range rows {
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
}
return out, nil
}
// DecideReview applies decision to the pending Rotation check of dinnerID
// and closes it, recording updatedBy: keep makes the dinner a Regular,
// drop archives it and not_made returns it to the Quest log; its origin is
// kept; a drop records the dinner's name on the check, so renaming the
// dinner later never takes it off the passed names. An unknown dinner is
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
// checks are all decided (such as a second decision) ErrNoPendingReview,
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
status, ok := decisionStatus[decision]
if !ok {
return Decided{}, &InvalidDecisionError{Value: decision}
}
if updatedBy == "" {
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
}
var out Decided
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
review, err := q.GetPendingRotationReview(ctx, d.ID)
if errors.Is(err, sql.ErrNoRows) {
checks, err := q.CountRotationReviews(ctx, d.ID)
if err != nil {
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
}
if checks == 0 {
return ErrReviewNotFound
}
return ErrNoPendingReview
}
if err != nil {
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
}
now := store.Now().String()
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
Status: status,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: d.ID,
})
if err != nil {
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
}
if moved != 1 {
return ErrDinnerNotTried
}
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
Decision: sql.NullString{String: decision, Valid: true},
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
Now: sql.NullString{String: now, Valid: true},
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
ID: review.ID,
})
if err != nil {
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
}
if closed != 1 {
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
}
out = Decided{
Dinner: reviewDinner(d),
PlanDate: review.PlanDate,
Decision: decision,
DinnerStatus: status,
DecidedAt: now,
UpdatedBy: updatedBy,
}
return nil
})
if err != nil {
return Decided{}, err
}
return out, nil
}
func reviewDinner(d db.Dinner) ReviewDinner {
out := ReviewDinner{ID: d.ID, Name: d.Name}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
+379
View File
@@ -0,0 +1,379 @@
package plan_test
import (
"database/sql"
"errors"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const reviewer = "member-1"
// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned
// on these days of fullWeek.
var (
padThaiDay = mustDate("2026-10-14")
shawarmaDay = mustDate("2026-10-16")
)
// putWeek stores fullWeek as the plan of week with status, as a draft and
// Push to Calendar would.
func (e *env) putWeek(t *testing.T, status string) {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range fullWeek {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i],
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
e.setStatus(t, status)
}
func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
t.Helper()
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
if err != nil {
t.Fatal(err)
}
return d
}
// review is a rotation_reviews row as stored.
type review struct {
planDate, status string
decision, name, decidedAt, updatedBy sql.NullString
}
func (e *env) reviews(t *testing.T, name string) []review {
t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(),
`SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []review
for rows.Next() {
var r review
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func (e *env) markTried(t *testing.T, date store.LocalDate, want int) {
t.Helper()
n, err := e.svc.MarkTried(t.Context(), date)
if err != nil {
t.Fatal(err)
}
if n != want {
t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want)
}
}
func TestMarkTried(t *testing.T) {
e := newEnv(t)
// Any plan status: a plan pushed to Calendar and edited still counts.
e.putWeek(t, plan.StatusChanged)
e.markTried(t, padThaiDay, 1)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" {
t.Errorf("Pad Thai status = %s, want tried", got)
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got)
}
// Idempotent: a second run moves nothing and opens no second check.
e.markTried(t, padThaiDay, 0)
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got)
}
// A day planning a Regular, or with no plan, moves nothing.
e.markTried(t, week, 0)
e.markTried(t, mustDate("2026-10-20"), 0)
if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
if got := e.reviews(t, "Beef Chili"); len(got) != 0 {
t.Errorf("Beef Chili reviews = %+v, want none", got)
}
}
// A dinner returned to the Quest log ("We didn't make it") is not marked
// tried again for the same day.
func TestMarkTriedAfterNotMade(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil {
t.Fatal(err)
}
e.markTried(t, padThaiDay, 0)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" {
t.Errorf("Pad Thai status = %s, want quest_log", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" {
t.Errorf("Pad Thai reviews = %+v, want the one decided", got)
}
}
func TestPendingReviews(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
got, err := e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("pending before MarkTried = %+v, want none", got)
}
e.markTried(t, shawarmaDay, 1)
e.markTried(t, padThaiDay, 1)
got, err = e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
want := []plan.Review{
{Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()},
{Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()},
}
if !slices.Equal(got, want) {
t.Errorf("pending = %+v, want %+v", got, want)
}
}
func TestDecideReview(t *testing.T) {
for _, tc := range []struct {
decision, status string
}{
{plan.DecisionKeep, "regular"},
{plan.DecisionDrop, "archived"},
{plan.DecisionNotMade, "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer)
if err != nil {
t.Fatal(err)
}
if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() ||
d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer {
t.Errorf("decided = %+v", d)
}
row := e.dinnerRow(t, "Pad Thai")
if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer {
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
}
// Only a drop records the dinner's name.
wantPassed := tc.decision == plan.DecisionDrop
wantName := sql.NullString{}
if wantPassed {
wantName = sql.NullString{String: "Pad Thai", Valid: true}
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
got[0].name != wantName {
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
}
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
}
// A second decision, of any kind, changes nothing.
for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} {
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) {
t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err)
}
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status {
t.Errorf("status after a second decision = %s, want %s", got, tc.status)
}
passed, err := db.New(e.st.Reader).ListPassedNames(t.Context())
if err != nil {
t.Fatal(err)
}
recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10)
if err != nil {
t.Fatal(err)
}
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
}
})
}
}
func TestDecideReviewRefused(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
ctx := t.Context()
var invalid *plan.InvalidDecisionError
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) {
t.Errorf("decision promote: %v, want *InvalidDecisionError", err)
}
if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) {
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
}
// Never tried: Shawarma Bowls is in the Quest log with no check.
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil {
t.Error("decision with no member succeeded")
}
// A tried dinner archived meanwhile is neither kept nor listed.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) {
t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err)
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" {
t.Errorf("Pad Thai status = %s, want archived", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews = %+v, want the one still pending", got)
}
if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 {
t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err)
}
}
// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation
// check's keep may make an existing dinner a Regular. Every query writing
// dinners.status and every non-test use of the regular status must be one
// of those reviewed below; a new one fails here until it is reviewed.
func TestOnlyKeepPromotesToRegular(t *testing.T) {
// Each query writing dinners.status, and why it cannot promote.
statusWriters := map[string]string{
"CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular",
"UpdateDinner": "dinners.Service.Update writes the status it read back",
"ArchiveDinner": "sets archived",
"MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived",
"MarkDinnerTried": "sets tried",
"MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular",
}
files, err := filepath.Glob("../../migrations/queries/*.sql")
if err != nil || len(files) == 0 {
t.Fatalf("no query files: %v", err)
}
writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`)
nameRe := regexp.MustCompile(`^\s*(\w+)`)
found := map[string]bool{}
for _, f := range files {
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, stmt := range strings.Split(string(src), "-- name:")[1:] {
name := nameRe.FindStringSubmatch(stmt)[1]
var body []string
for _, line := range strings.Split(stmt, "\n")[1:] {
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
body = append(body, line)
}
}
sqlText := strings.Join(body, "\n")
if !writes.MatchString(sqlText) {
continue
}
found[name] = true
if _, ok := statusWriters[name]; !ok {
t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name)
}
if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) {
t.Errorf("%s: query %s sets status regular outright", f, name)
}
}
}
for name := range statusWriters {
if !found[name] {
t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name)
}
}
// Each non-test Go file naming the regular status, and how often.
uses := map[string]int{
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
"plan/plan.go": 1, // plannable
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
"ai/harness/context.go": 1, // reads the Regulars
"ai/skills/draft_week.go": 1, // KindRegular, a planned day's label
}
regular := regexp.MustCompile(`StatusRegular\b|"regular"`)
got := map[string]int{}
err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "db" {
return filepath.SkipDir // generated; the queries are checked above
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
if n := len(regular.FindAllIndex(src, -1)); n > 0 {
got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n
}
return nil
})
if err != nil {
t.Fatal(err)
}
for path, n := range got {
if uses[path] != n {
t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path])
}
}
for path, n := range uses {
if got[path] == 0 {
t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n)
}
}
}
+4 -2
View File
@@ -107,8 +107,8 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, or planning a dinner that is neither a Regular nor
// in the Quest log, is 409.
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
// the Quest log, or one already on another day of the week, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
@@ -120,6 +120,8 @@ func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger,
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
+3 -1
View File
@@ -32,6 +32,7 @@ const plansPath = "/api/v1/plans/"
type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
questLog string
}
@@ -89,7 +90,7 @@ func newPlansRouter(t *testing.T) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
@@ -308,6 +309,7 @@ func TestPutPlanDay(t *testing.T) {
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code {
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
const maxRotationReviewBody = 1 << 10
// rotationDecision is the POST /dinners/{id}/rotation-review body:
// keep, drop ("Not for us") or not_made ("We didn't make it").
type rotationDecision struct {
Decision *string `json:"decision"`
}
type rotationReviewList struct {
Reviews []plan.Review `json:"reviews"`
}
// getRotationReviews answers GET /rotation-reviews with the pending
// Rotation checks, oldest plan date first.
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.PendingReviews(r.Context())
if err != nil {
writeRotationError(w, r, logger, "list Rotation checks", err)
return
}
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
}
}
// postRotationReview applies the household's answer to "Keep it in
// rotation?" for a tried dinner, recording the session's actor as
// updated_by, and answers with the Rotation check as decided.
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Decision == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
return
}
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
if err != nil {
writeRotationError(w, r, logger, "decide Rotation check", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// writeRotationError maps a Rotation check error to its API error: a
// decision other than keep, drop or not_made is 400, an unknown dinner or
// a dinner never checked 404, and a dinner whose checks are all decided
// (such as a second decision) or no longer tried 409.
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *plan.InvalidDecisionError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
case errors.Is(err, plan.ErrReviewNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
case errors.Is(err, plan.ErrNoPendingReview):
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
case errors.Is(err, plan.ErrDinnerNotTried):
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"net/http"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const rotationReviewsPath = "/api/v1/rotation-reviews"
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
// and marks the day tried, so Pad Thai has a pending Rotation check.
func triedPadThai(t *testing.T, e plansEnv) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
}
func TestGetRotationReviews(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
}
triedPadThai(t, e)
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
reviews, _ := body["reviews"].([]any)
if len(reviews) != 1 {
t.Fatalf("reviews = %v, want one", body)
}
r := reviews[0].(map[string]any)
d, _ := r["dinner"].(map[string]any)
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
}
}
func TestPostRotationReview(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ decision, status string }{
{"keep", "regular"},
{"drop", "archived"},
{"not_made", "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
t.Errorf("decided = %v", body)
}
var status, origin, updatedBy string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
Scan(&status, &origin, &updatedBy); err != nil {
t.Fatal(err)
}
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
}
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
}
})
}
}
// A dinner that has never had a Rotation check is 404; a second decision
// on one already decided is 409 (TestPostRotationReview).
func TestPostRotationReviewNeverChecked(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "quest_log" {
t.Errorf("status after a refused decision = %s, want quest_log", status)
}
}
func TestPostRotationReviewRefused(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
for _, tc := range []struct {
name, target, body string
code int
errCode string
}{
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
if rec.Code != tc.code || errorCode(body) != tc.errCode {
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
}
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "tried" {
t.Errorf("status after refused decisions = %s, want tried", status)
}
}
+5 -2
View File
@@ -43,8 +43,9 @@ 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 and PUT
// /api/v1/plans/{weekStart}/days/{date}; runner serves
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
@@ -125,6 +126,8 @@ 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))
+89 -4
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" {
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 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)
}
}
@@ -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" {
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 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 !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -633,3 +633,88 @@ 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 declined
// Side quests given the household context, the deck and every passed
// name, then grounds them in the model's order, at most the number still
// 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 declined name. A household with no Regulars
// the deck's names and every passed name (ListPassedNames). 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.ListDeclinedSuggestionNames(ctx)
declined, err := q.ListPassedNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err)
}
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
}
@@ -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 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.
// 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.
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.ListDeclinedSuggestionNames(ctx)
names, err := q.ListPassedNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
+70 -1
View File
@@ -21,6 +21,7 @@ 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"
@@ -646,7 +647,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).ListDeclinedSuggestionNames(t.Context())
names, err := db.New(e.st.Reader).ListPassedNames(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -912,6 +913,74 @@ func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
}
}
// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its
// Rotation check ("Not for us"), renames its archived dinner, and checks
// the next batch still refuses the name it was dropped under.
func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
e := newEnv(t)
ctx := t.Context()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, logger)
if err != nil {
t.Fatal(err)
}
id := e.dinner(t, "Pad Thai", "quest_log")
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: "2026-10-14",
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := planSvc.MarkTried(ctx, day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
if _, err := planSvc.DecideReview(ctx, id, plan.DecisionDrop, member); err != nil {
t.Fatal(err)
}
renamed := "Peanut Noodles"
if _, err := dinnerSvc.Update(ctx, id, dinners.Patch{Name: &renamed}, member); err != nil {
t.Fatal(err)
}
// The respelled dropped dish would ground, so only the passed names
// keep it out of the deck.
e.recipe(t, "pad thai!")
e.recipe(t, "Beef Ragu")
e.reply(
sideQuest("pad thai!", "Noodles, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
)
e.start(t)
res := e.runBatch(t)
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
t.Errorf("batch result = %+v, want 1 kept and the dropped dish dropped", res)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
e.mu.Lock()
in := e.inputs[0]
e.mu.Unlock()
if !slices.Equal(in.Declined, []string{"Pad Thai"}) || !slices.Equal(in.Context.Passed, []string{"Pad Thai"}) {
t.Errorf("batch declined %v, passed %v; want the name it was dropped under in both", in.Declined, in.Context.Passed)
}
}
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
// job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish.
+43
View File
@@ -0,0 +1,43 @@
-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us")
-- keeps the name its dinner had when the household dropped it. The dinner
-- can be renamed, even once archived, so the passed names every later
-- batch refuses are read from here, never from the dinner, as 0012 does
-- for a Pass. Only a drop has a name. A check dropped before this takes
-- its dinner's name now.
-- SQLite cannot add a column with a CHECK that existing drops would fail
-- before they are filled in, so the table is rebuilt as 0012 does: copy
-- the rows aside, drop the table, create it again, copy the rows back and
-- drop the copy. Nothing references rotation_reviews.
CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews;
DROP TABLE rotation_reviews;
CREATE TABLE rotation_reviews (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
plan_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
name TEXT CHECK (name <> ''), -- the dinner's name when dropped
decided_at TEXT,
updated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (dinner_id, plan_date),
CHECK ((decision IS 'drop') = (name IS NOT NULL))
);
INSERT INTO rotation_reviews (
id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at
)
SELECT
rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date,
rotation_reviews_copy.status, rotation_reviews_copy.decision,
CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END,
rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by,
rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at
FROM rotation_reviews_copy
JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id;
DROP TABLE rotation_reviews_copy;
+12 -6
View File
@@ -1,11 +1,17 @@
-- Reads for the AI household context block (internal/ai/harness).
-- 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'
-- 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'
)
ORDER BY decided_at DESC, id DESC
LIMIT ?;
+66
View File
@@ -0,0 +1,66 @@
-- name: ListTriedCandidates :many
-- The ids of the dinners with status quest_log planned on date, on a plan
-- of any status, that have no Rotation check for date yet.
SELECT DISTINCT dinners.id
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.date = sqlc.arg(date)
AND dinners.status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM rotation_reviews
WHERE rotation_reviews.dinner_id = dinners.id
AND rotation_reviews.plan_date = plan_days.date
)
ORDER BY dinners.id;
-- name: MarkDinnerTried :execrows
-- Moves a dinner still in the Quest log to tried; a dinner with any other
-- status is left as it is (0 rows).
UPDATE dinners
SET status = 'tried', updated_at = ?
WHERE id = ? AND status = 'quest_log';
-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?);
-- name: ListPendingRotationReviews :many
-- The pending Rotation checks whose dinner is still tried, oldest plan
-- date first, with their dinners.
SELECT rotation_reviews.plan_date, sqlc.embed(dinners)
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
ORDER BY rotation_reviews.plan_date, rotation_reviews.id;
-- name: GetPendingRotationReview :one
-- The pending Rotation check of dinner_id. MarkTried makes one only for a
-- dinner in the Quest log, which it moves to tried, so a dinner has at
-- most one. Returns sql.ErrNoRows when there is none.
SELECT * FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending';
-- name: CountRotationReviews :one
-- How many Rotation checks dinner_id has had, pending or decided.
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?;
-- name: DecideRotationReview :execrows
-- Records the household's decision on a pending Rotation check, with the
-- dinner's name for a drop and NULL otherwise; one already decided is left
-- as it is (0 rows).
UPDATE rotation_reviews
SET status = 'decided',
decision = sqlc.arg(decision),
name = sqlc.narg(name),
decided_at = sqlc.arg(now),
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: MoveTriedDinner :execrows
-- Moves a dinner still tried to status (regular, archived or quest_log)
-- by a Rotation check decision, recording updated_by; its origin is kept.
-- A dinner no longer tried is left as it is (0 rows).
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'tried';
+14 -6
View File
@@ -86,12 +86,20 @@ FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- 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'
-- 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'
)
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows
+19 -18
View File
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -165,19 +166,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
})
@@ -185,12 +186,12 @@ describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
renderAt('/quick')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -258,7 +259,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/plan', 'Plan'],
['/quick', 'Quick dinners'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -282,7 +283,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/plan')
renderAt('/quick')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -290,7 +291,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -299,15 +300,15 @@ describe('App offline bootstrap', () => {
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/plan')
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/plan')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -316,12 +317,12 @@ describe('App offline bootstrap', () => {
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/plan')
const { location, unmount } = renderAt('/quick')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/plan')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -329,13 +330,13 @@ describe('App offline bootstrap', () => {
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/plan">
<InDataRouter path="/quick">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -350,6 +351,6 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
})
+2
View File
@@ -7,6 +7,7 @@ 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'
@@ -36,6 +37,7 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
+10
View File
@@ -225,4 +225,14 @@ 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 })
})
})
+19 -1
View File
@@ -43,11 +43,18 @@ 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
@@ -114,6 +121,16 @@ 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
@@ -131,7 +148,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 decode<T>(method, path, response)
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
}
// parseForbidden reads a 403. Any API error other than csrf means the
@@ -179,6 +196,7 @@ 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 ApiLocation, type ApiRequest, type Me } from './client.ts'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
+1 -1
View File
@@ -20,7 +20,7 @@ export type QuestLogSheetProps = {
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
// neither is known.
function details(dinner: QuestLogDinner): string | null {
export function details(dinner: QuestLogDinner): string | null {
const parts = [
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
+8 -6
View File
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
// it: photo, name, difficulty and minutes.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
// it: photo, name, difficulty and minutes, with the method a swap carries
// onto the plan.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
@@ -127,8 +128,8 @@ export function useQuestLog() {
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, minutes_total }
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, method, minutes_total }
})
},
})
@@ -250,13 +251,14 @@ export function useDecide() {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
? dinners
: // The deck does not say the minutes; the Quest log read after the
// decision does.
: // The deck does not say the method or minutes; the Quest log
// read after the decision does.
dinners.concat({
id: quest.dinner_id,
name: quest.name,
photo_id: quest.photo_id,
difficulty: quest.difficulty,
method: null,
minutes_total: null,
}),
)
+589
View File
@@ -0,0 +1,589 @@
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
.page {
box-sizing: border-box;
}
.board {
box-sizing: border-box;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobilePage {
max-width: 440px;
padding-top: 8px;
}
/* The grid fills the screen below the header: the main area's 32 px top and
bottom padding (AppShell) is taken back off the viewport height. */
.tabletPage .board {
min-height: calc(100dvh - 64px);
}
/* Desktop: the board beside the 290 px right panel, which runs the full
height of the screen to its right edge over the main area's padding. */
.desktopPage {
display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
min-height: 100dvh;
margin: -32px -36px -32px 0;
}
.desktopPage .board {
padding: 32px 36px 32px 0;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.heading {
min-width: 0;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 26px;
font-weight: 400;
line-height: 1.1;
}
.weekNav {
flex: none;
display: flex;
gap: 8px;
}
/* The assistant: a 32 px grape "Q" avatar beside its note. */
.assistant {
display: flex;
align-items: flex-start;
gap: 10px;
}
.avatar {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
font-family: var(--font-heading);
font-size: 16px;
line-height: 1;
}
.bubble {
margin: 0;
padding: 12px 14px;
border-radius: 4px 18px 18px 18px;
background: var(--color-surface);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
}
/* Two columns of 86 px day tiles; the 8th cell is "+ Rebuild week". */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.tile {
box-sizing: border-box;
display: flex;
min-height: 86px;
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
}
/* The whole tile is the button that opens the swap sheet. */
.tileButton {
box-sizing: border-box;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
gap: 6px;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
/* A Quest-log pick. */
.newTile {
background: var(--color-accent-2-300);
}
.dayKicker {
display: block;
margin: 0;
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.newTile .dayKicker {
color: var(--color-accent-2-900);
}
.dinner {
display: block;
margin: 0;
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1.15;
overflow-wrap: anywhere;
}
.startBy {
display: block;
margin: 4px 0 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
color: var(--color-neutral-700);
}
.newTile .startBy {
color: var(--color-accent-2-900);
}
/* The day being swapped, while the swap sheet is open: the page background
ringed in grape, its name muted. */
.swapping {
background: var(--color-bg);
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.swapping .dayKicker {
color: var(--color-accent-700);
}
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-600);
}
.rebuildCell {
display: flex;
}
.rebuild {
box-sizing: border-box;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 86px;
padding: 12px;
border: 2px dashed var(--color-neutral-400);
border-radius: 22px;
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.rebuild:hover:not(:disabled) {
background: var(--color-neutral-100);
}
.rebuild:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.loading {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeletonTile {
height: 86px;
border-radius: 22px;
}
.drafting {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
the shopping list until Feature 17): it keeps focus, so hovering, tapping
or focusing it shows the tooltip. */
.unavailable {
position: relative;
display: flex;
flex-direction: column;
}
.pushButton {
min-height: 50px;
}
.unavailable [aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
/* Unavailable, it neither darkens nor presses. */
.unavailable .primary[aria-disabled='true']:hover,
.unavailable .primary[aria-disabled='true']:active {
background: var(--color-accent);
transform: none;
}
.unavailable .outline[aria-disabled='true']:hover,
.unavailable .outline[aria-disabled='true']:active {
background: transparent;
transform: none;
}
.hint {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--color-text);
color: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
text-align: center;
box-shadow: var(--shadow-md);
visibility: hidden;
pointer-events: none;
}
/* In the plan manager's header the tooltip opens below, at the action's
right edge. */
.below .hint {
top: calc(100% + 8px);
bottom: auto;
left: auto;
right: 0;
transform: none;
width: 240px;
max-width: none;
z-index: 1;
}
.unavailable:hover .hint,
.unavailable [aria-disabled='true']:focus + .hint {
visibility: visible;
}
/* The plan manager's header: kicker and title beside the week buttons, over
the sync status and the actions. */
.wideHeader {
display: flex;
flex-direction: column;
gap: 14px;
}
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.wideTitle {
margin: 4px 0 0;
font-family: var(--font-heading);
font-size: 40px;
font-weight: 400;
line-height: 1.1;
}
.tabletPage .wideTitle {
font-size: 36px;
}
.wideHeader .kicker {
font-size: 12px;
}
.wideHeader .weekNav {
align-items: center;
gap: 12px;
}
.thisWeek {
min-height: 44px;
padding: 10px 16px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.thisWeek:hover {
background: var(--color-neutral-100);
}
.actionBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
/* The status keeps to one line; the actions wrap under it when the row is
too narrow for both. */
.status {
flex: 1 0 auto;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.synced {
color: var(--color-accent-2-800);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.action {
min-height: 44px;
padding: 0 18px;
white-space: nowrap;
}
/* The week: seven columns, each a day label over its card. A column is
never narrower than 112 px, which holds a dinner's words whole and the
tag beside the swap icon; where seven of those do not fit (tablet's
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
padding, taken back by the margin, keeps the swap icons' hit boxes and
focus rings inside the scroll area. */
.week {
flex: 1;
display: grid;
grid-template-columns: repeat(7, minmax(112px, 1fr));
gap: 12px;
margin: -8px;
padding: 8px;
overflow-x: auto;
list-style: none;
}
.column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.dayLabel {
display: flex;
align-items: baseline;
gap: 6px;
margin: 0;
padding: 0 4px;
}
.dayName {
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.dayNumber {
font-family: var(--font-heading);
font-size: 22px;
color: var(--color-text);
}
.card {
position: relative;
box-sizing: border-box;
flex: 1;
display: flex;
flex-direction: column;
gap: 9px;
padding: 12px;
border-radius: 26px;
background: var(--color-surface);
color: var(--color-text);
}
.tablet .card {
gap: 8px;
padding: 10px;
border-radius: 24px;
}
/* A Side quest's card. */
.sideQuestCard {
background: var(--color-accent-2-200);
}
.cardPhoto {
width: 100%;
height: 110px;
}
.tablet .cardPhoto {
height: 96px;
}
.cardName {
margin: 0;
font-family: var(--font-heading);
font-size: 17px;
line-height: 1.15;
overflow-wrap: break-word;
}
.tablet .cardName {
font-size: 16px;
}
.cardMinutes,
.cardStartBy {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-800);
}
.cardStartBy {
font-weight: 700;
}
.sideQuestCard .cardMinutes,
.sideQuestCard .cardStartBy {
color: var(--color-accent-2-900);
}
.cardFooter {
margin-top: auto;
display: flex;
align-items: center;
gap: 4px;
}
/* The day a dragged dinner drops on: a dashed 2.5 px grape border on
grape-100 over the card, saying which dinner it replaces. */
.dropTarget {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 12px;
border: 2.5px dashed var(--color-accent);
border-radius: inherit;
background: var(--color-accent-100);
text-align: center;
}
.dropTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
color: var(--color-accent-700);
}
.dropReplaces {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
overflow-wrap: break-word;
}
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
keeps the 16 px footprint of its icon, reaching over the card's padding
and the tag. */
.cardFooter .swapButton {
margin: -14px;
}
.cardFooter .cardTag {
margin-right: auto;
padding: 3px 8px;
}
.swapButton > span {
background: transparent;
color: var(--color-neutral-700);
}
.skeletonCard {
height: 300px;
border-radius: 26px;
}
File diff suppressed because it is too large Load Diff
+599
View File
@@ -0,0 +1,599 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
DRAFT_JOB_KIND,
parseDraftResult,
planKey,
usePlan,
useStartDraft,
useSwapDay,
type DraftResult,
type Plan,
type PlanDay,
type PlanDinner,
type PlanStatus,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
import { SwapSheet } from './SwapSheet.tsx'
import {
addDays,
dayOfMonth,
defaultWeek,
localNow,
weekdayKicker,
weekdayName,
weekLabel,
weekStart as weekOf,
} from './week.ts'
import styles from './PlanPage.module.css'
// statusTags is the tag beside the week's title for each plan status.
const statusTags: Record<PlanStatus, { label: string; tone: 'grape' | 'teal' }> = {
draft: { label: 'Draft', tone: 'grape' },
synced: { label: 'Synced', tone: 'teal' },
changed: { label: 'Changes not pushed', tone: 'grape' },
}
// draftFailures says why a draft_week job failed, by its failure code. Any
// other code is shown as it is.
const draftFailures: Record<string, string> = {
not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.',
ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar while it was drafting.',
plan_changed: 'The plan was edited while it was drafting.',
pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.',
internal_error: 'Something went wrong on the server.',
}
// PUSH_HINT is why Push to Calendar is unavailable.
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet',
synced: 'Synced to Google Calendar',
changed: 'Changes not pushed to Google Calendar',
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
export function duration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest}\u00a0min`
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
}
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
// whichever of the two is known, or null when neither is.
export function cooking(dinner: PlanDinner): string | null {
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
(part): part is string => typeof part === 'string',
)
return parts.length === 0 ? null : parts.join(' · ')
}
// ago is how long before now the instant at was: "just now", "2 min ago",
// "3 hr ago", "2 days ago".
export function ago(at: Date, now: Date): string {
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hr ago`
const days = Math.floor(hours / 24)
return days === 1 ? '1 day ago' : `${days} days ago`
}
// pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean {
return status !== 'draft'
}
// DayTile is one day of the Draft board: a Regular on the surface, a
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
// start-by time in the household's zone. Tapping it opens the swap sheet;
// while that is open the tile is swapping: on the page background, ringed in
// grape, its name muted.
function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const kicker = weekdayKicker(day.date)
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
return (
<li className={classes}>
<button type="button" className={styles.tileButton} onClick={onSwap}>
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
<span>
<span className={styles.dinner}>{day.dinner.name}</span>
{day.start_by !== null && (
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
)}
</span>
</button>
</li>
)
}
// DayColumn is one day of the plan manager's 7-column grid: its day label
// over a card with the dinner's washed photo, name, method and time, a
// crock-pot dinner's start-by time in the household's zone, and its Regular
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
// sheet; while that is open the card is swapping, ringed in grape. A dinner
// dragged over the day drops on it: the card is swapping, covered by a
// dashed grape "Drop to swap" saying which dinner it replaces.
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const { dinner } = day
const time = cooking(dinner)
const target = useDropTarget(day.date) !== null
const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ')
return (
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
<p className={styles.dayLabel} aria-hidden="true">
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
</p>
<div className={classes}>
{/* The card's name already says what the photo shows. */}
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
</span>
<p className={styles.cardName}>{dinner.name}</p>
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
{day.start_by !== null && (
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
)}
<div className={styles.cardFooter}>
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
<Icon icon={RefreshCw} size="inline" />
</IconButton>
</div>
{target && (
<div className={styles.dropTarget}>
<p className={styles.dropTitle}>Drop to swap</p>
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
</div>
)}
</div>
</li>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
const wide = layout !== 'mobile'
return (
<section aria-busy="true" aria-label={label} className={styles.loading}>
<div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
))}
</div>
{children !== undefined && <p className={styles.drafting}>{children}</p>}
</section>
)
}
// WeekBoard is the Draft board of the week starting on weekStart. Drafting
// it — the empty state's "Draft this week" or the "+ Rebuild week" tile —
// starts the draft_week job and shows skeleton tiles until the job ends and
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
//
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
// unavailable Shopping list and Push to Calendar are actions in the header,
// over the plan's sync status and the 7-column grid.
function WeekBoard({
weekStart,
timezone,
layout,
onWeek,
}: {
weekStart: string
timezone: string
layout: Breakpoint
onWeek: (weekStart: string) => void
}) {
const queryClient = useQueryClient()
const plan = usePlan(weekStart)
const startDraft = useStartDraft()
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
const label = weekLabel(weekStart)
// The draft's result, once the job has succeeded, or why it cannot be read.
const result = useMemo((): { draft: DraftResult | null; error: Error | null } => {
if (job.status !== 'succeeded') return { draft: null, error: null }
try {
return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null }
} catch (error) {
return { draft: null, error: error as Error }
}
}, [job.status, job.result, jobId, weekStart])
// Once the job ends the plan is read again: a succeeded draft is followed
// until that read lands; a failed one stays shown, and may have met a
// plan changed meanwhile. The Quest log is read again too, as after a
// swap: the draft may have planned some of it.
const [readJobId, setReadJobId] = useState<string | null>(null)
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
let active = true
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId)
})
return () => {
active = false
}
}, [queryClient, ended, jobId, weekStart])
// The plan read shows once it is the one drafted, read from the server; no
// plan, another one, or a copy cached before the draft (a redraft keeps the
// plan's id) is an error rather than an empty or stale board. Its Retry
// reads the plan again, keeping the job: a later read from the server that
// is the draft still lands it.
const { draft } = result
const read = draft !== null && readJobId === jobId && plan.isSuccess
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
const unreadError =
read && !landed
? new Error(
plan.data === null
? `The week has no plan, not draft ${draft.plan_id}.`
: plan.data.id !== draft.plan_id
? `The week's plan is not draft ${draft.plan_id}.`
: `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`,
)
: null
const resultError = result.error ?? unreadError
useEffect(() => {
if (landed) setJobId(null)
}, [landed])
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
const { mutate } = startDraft
function start(confirm: boolean) {
setConfirming(false)
// A draft asked for again leaves the last one's failure, and a failed
// swap's, behind.
setJobId(null)
swap.dismiss()
mutate(
{ weekStart, confirm },
{
onSuccess: setJobId,
// A plan pushed to Calendar meanwhile needs confirm: read it again.
onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }),
},
)
}
// rebuild drafts the week, asking first over a plan pushed to Calendar.
// It waits for a swap of the week still pending, which may yet fail: the
// Rebuild week tile is disabled meanwhile.
function rebuild() {
if (swap.pending) return
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
else start(false)
}
const failure = swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
) : startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
) : unreadError !== null ? (
<ErrorBanner onRetry={() => void plan.refetch()}>Couldn't read the draft. {unreadError.message}</ErrorBanner>
) : job.error === null ? null : job.status === 'failed' ? (
<ErrorBanner onRetry={rebuild}>
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
</ErrorBanner>
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
// nor dropped, while a swap of the week is pending.
function dropIn(chosen: Swap) {
swap.dismiss()
swapIn(chosen)
}
// openSwap opens the swap sheet on a day. One swap of the week is sent at
// a time, even across leaving the week and coming back: the plan the
// server answers it with must not overwrite another still pending.
function openSwap(date: string) {
if (swap.pending) return
swap.dismiss()
setSwappingDate(date)
}
let body
if (plan.isError) {
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
} else if (drafting) {
body = (
<SkeletonTiles label="Drafting this week" layout={layout}>
Drafting your week…
</SkeletonTiles>
)
} else if (resultError !== null) {
// An unread draft shows no board: the plan read is not the draft.
body = null
} else if (plan.isPending) {
body = <SkeletonTiles label="Plan loading" layout={layout} />
} else if (plan.data === null) {
body = (
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
I'll draft seven dinners from your Regulars and Quest log.
</EmptyState>
)
} else {
const { ai_note, days } = plan.data
const swappingDay = days.find((day) => day.date === swappingDate)
const sheet = swappingDay !== undefined && (
<SwapSheet
day={swappingDay}
planned={new Set(days.map((day) => day.dinner.id))}
onSwap={swapIn}
onClose={() => setSwappingDate(null)}
/>
)
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
</ul>
{sheet}
</>
) : (
<>
{ai_note !== null && (
<div className={styles.assistant}>
<span className={styles.avatar} aria-hidden="true">
Q
</span>
<p className={styles.bubble}>{ai_note}</p>
</div>
)}
<ul className={styles.grid} aria-label="Dinners this week">
{days.map((day) => (
<DayTile
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
Looks good — push to Calendar
</Unavailable>
{sheet}
</>
)
}
const tag = plan.data ? statusTags[plan.data.status] : null
const header =
layout !== 'mobile' ? (
<header className={styles.wideHeader}>
<div className={styles.titleBar}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week} · Plan</p>
<h1 className={styles.wideTitle}>{label.dates}</h1>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
{layout === 'desktop' && (
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
This week
</button>
)}
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</div>
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
<Icon icon={RefreshCw} size="inline" />
Rebuild week
</Button>
<Unavailable hint={PUSH_HINT} below className={styles.action}>
<Icon icon={CalendarDays} size="inline" />
Push to Calendar
</Unavailable>
</div>
</div>
</header>
) : (
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>{label.dates}</h1>
{tag !== null && <Tag tone={tag.tone}>{tag.label}</Tag>}
</div>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</header>
)
return (
<>
{header}
{body}
{failure}
<Confirm
open={confirming}
title="Rebuild this week?"
confirmLabel="Rebuild week"
cancelLabel="Keep this plan"
onCancel={() => setConfirming(false)}
onConfirm={() => start(true)}
>
This week is already pushed to Calendar. The AI assistant drafts a new plan in its place.
</Confirm>
</>
)
}
// SyncStatus is the plan manager's status line: whether the plan is on
// Google Calendar and, once synced, how long ago it was pushed, kept current
// minute by minute.
function SyncStatus({ plan }: { plan: Plan }) {
const { status, calendar_synced_at: syncedAt } = plan
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 60_000)
return () => clearInterval(timer)
}, [])
return (
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
{status === 'synced' && <Icon icon={Check} size="inline" />}
{syncStatuses[status]}
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
</p>
)
}
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
// shopping list): it stays focusable, so a tap or focus shows the tooltip
// saying why, above it or, at the top of the page, below.
function Unavailable({
hint,
variant = 'primary',
below = false,
className,
children,
}: {
hint: string
variant?: ButtonVariant
below?: boolean
className?: string
children: ReactNode
}) {
const hintId = useId()
return (
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button
variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{hint}
</span>
</div>
)
}
// PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
// Regulars in a right panel on desktop (2d). It opens on next week once this
// week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
export function PlanPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
let content
if (settings.isError) {
content = (
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
)
} else if (week === null || !settings.data) {
content = <SkeletonTiles label="Plan loading" layout={layout} />
} else {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
}
return (
<PlanDragProvider>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
</div>
{layout === 'desktop' && <PlanPanel />}
{layout !== 'mobile' && <DragAnnouncer />}
</div>
</PlanDragProvider>
)
}
+223
View File
@@ -0,0 +1,223 @@
/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */
.panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
padding: 32px 22px;
border-left: 1px solid var(--color-divider);
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 22px;
background: var(--color-surface);
text-align: left;
}
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
"dish photo" caption does not fit, so only its stripes show. */
.rowPhoto {
width: 48px;
height: 48px;
font-size: 0;
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-family: var(--font-body);
}
.rowName {
font-size: 14px;
font-weight: 700;
overflow-wrap: anywhere;
}
.rowMeta {
font-size: 12px;
color: var(--color-neutral-700);
}
/* Two columns of three 4 px dots. */
.grip {
flex: none;
display: grid;
grid-template-columns: 4px 4px;
gap: 3px;
}
.dot {
width: 4px;
height: 4px;
border-radius: var(--radius-pill);
background: var(--color-neutral-500);
}
.more {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
.more:hover {
text-decoration: underline;
}
.loading {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton {
height: 68px;
border-radius: 22px;
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
/* Tablet: the Quest log as chips on a surface card under the grid. */
.strip {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 18px;
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.stripHeader {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.stripTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
}
.stripHint {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.chips {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 5px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
/* As the row's photo, at 28 px. */
.chipPhoto {
width: 28px;
height: 28px;
font-size: 0;
}
/* A row or a chip is a button dragged onto a day: by a pointer (no
scrolling or text selection under it) or with the keyboard. Its 2 px
border is clear until it is the ghost, inside its padding. */
.draggable {
margin: 0;
border: 2px solid transparent;
font: inherit;
color: var(--color-text);
cursor: grab;
touch-action: none;
user-select: none;
}
.row.draggable {
padding: 8px;
}
.chip.draggable {
padding: 3px 12px 3px 3px;
}
/* A dinner that cannot be picked up now: no grid to drop on, a swap
pending, or already this week. */
.draggable[aria-disabled='true'] {
cursor: default;
}
/* The row or chip being dragged: a dashed ghost at 55% opacity. */
.ghost {
border-style: dashed;
border-color: var(--color-neutral-500);
background: transparent;
opacity: 0.55;
}
/* The dragged copy floating below right of the pointer: lifted with the
large shadow and tilted -3deg. It lets the pointer through to the day
under it. */
.floating {
position: fixed;
z-index: 10;
box-sizing: border-box;
margin: 0;
box-shadow: var(--shadow-lg);
transform: rotate(-3deg);
pointer-events: none;
cursor: grabbing;
}
+210
View File
@@ -0,0 +1,210 @@
import { Compass } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Link } from 'react-router'
import { useRegulars } from '../dinners/api.ts'
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useDraggable, type Float } from './drag.tsx'
import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx'
import styles from './PlanPanel.module.css'
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
const DRAG_HINT = 'Drag a dinner onto a day to swap it in.'
// KEYS_HINT says how a dinner is swapped in with the keyboard.
const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.'
type List = ReturnType<typeof useQuestLog>
// ListBody is a list of the panel or the strip once it has loaded, or why it
// has not: its error with Retry, skeletons while loading, or that it is
// empty.
function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) {
if (list.isError) {
return (
<ErrorBanner onRetry={() => void list.refetch()}>
Couldn't load your {sourceNames[source]}. {list.error.message}
</ErrorBanner>
)
}
if (list.isPending) {
return (
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeleton} />
))}
</div>
)
}
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
return children(list.data)
}
// Grip is the 6-dot drag handle of a panel row.
function Grip() {
return (
<span className={styles.grip} aria-hidden="true">
{Array.from({ length: 6 }, (_, i) => (
<span key={i} className={styles.dot} />
))}
</span>
)
}
// Draggable is a dinner of the panel or the strip, dragged onto a day to
// swap it in: a button whose content is the row or the chip. While it is
// dragged it is a dashed ghost, and with a pointer a copy of it floats
// below right of the pointer, lifted and tilted.
function Draggable({
dinner,
fromQuestLog,
className,
keysHintId,
children,
}: {
dinner: QuestLogDinner
fromQuestLog: boolean
className: string | undefined
keysHintId: string
children: ReactNode
}) {
const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog })
return (
<li>
<button
type="button"
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
aria-describedby={keysHintId}
{...props}
>
{children}
</button>
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
</li>
)
}
// Floating is the dragged copy of a row or a chip, drawn over the page.
function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) {
return createPortal(
<div
aria-hidden="true"
data-testid="drag-float"
className={[className, styles.floating].filter(Boolean).join(' ')}
style={{ left: float.left, top: float.top, width: float.width }}
>
{children}
</div>,
document.body,
)
}
// KeysHint is the keyboard's way to swap a dinner in, describing each
// draggable.
function KeysHint({ id }: { id: string }) {
return (
<p id={id} hidden>
{KEYS_HINT}
</p>
)
}
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
// quests.
export function PlanPanel() {
const questLog = useQuestLog()
const regulars = useRegulars()
const [source, setSource] = useState<Source>('quest_log')
const lists = { quest_log: questLog, regulars }
const keysHintId = useId()
const label = (s: Source) => {
const dinners = lists[s].data
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
}
return (
<aside aria-label="Quest log and Regulars" className={styles.panel}>
<Segmented
label="Dinners to swap in"
options={[
{ value: 'quest_log', label: label('quest_log') },
{ value: 'regulars', label: label('regulars') },
]}
value={source}
onValueChange={setSource}
/>
<p className={styles.hint}>{DRAG_HINT}</p>
<KeysHint id={keysHintId} />
<ListBody source={source} list={lists[source]}>
{(dinners) => (
<ul aria-label={sourceNames[source]} className={styles.rows}>
{dinners.map((dinner) => (
<Draggable
key={dinner.id}
dinner={dinner}
fromQuestLog={source === 'quest_log'}
className={styles.row}
keysHintId={keysHintId}
>
{/* The row's name already says what the photo shows. */}
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
</span>
<span className={styles.rowText}>
<span className={styles.rowName}>{dinner.name}</span>
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
</span>
<Grip />
</Draggable>
))}
</ul>
)}
</ListBody>
<Link to="/discover" className={styles.more}>
<Icon icon={Compass} size="inline" />
Swipe more side quests
</Link>
</aside>
)
}
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
// dinner a chip with its 28 px photo and name, dragged as the panel's rows
// are.
export function QuestLogStrip() {
const questLog = useQuestLog()
const keysHintId = useId()
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
return (
<section aria-label={sourceNames.quest_log} className={styles.strip}>
<div className={styles.stripHeader}>
<h2 className={styles.stripTitle}>{title}</h2>
<p className={styles.stripHint}>{DRAG_HINT}</p>
</div>
<KeysHint id={keysHintId} />
<ListBody source="quest_log" list={questLog}>
{(dinners) => (
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
{dinners.map((dinner) => (
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
</span>
{dinner.name}
</Draggable>
))}
</ul>
)}
</ListBody>
</section>
)
}
+57
View File
@@ -0,0 +1,57 @@
/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel
(mock 2d) as a bottom sheet. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.replaces {
margin: 2px 0 0;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
color: var(--color-neutral-700);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* The 48 px washed photo; Photo applies the washed filter. */
.photo {
width: 48px;
height: 48px;
}
/* A dinner already this week: shown, muted, and not a button. */
.already {
color: var(--color-neutral-600);
}
.skeletonRow {
height: 72px;
border-radius: var(--radius-md);
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+129
View File
@@ -0,0 +1,129 @@
import { useId, useState } from 'react'
import { useRegulars } from '../dinners/api.ts'
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
import { details } from '../discover/QuestLogSheet.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './SwapSheet.module.css'
// Source is a list a dinner is swapped in from.
export type Source = 'quest_log' | 'regulars'
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
export const emptyLists: Record<Source, string> = {
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
regulars: 'You have no Regulars yet.',
}
// planDinner is a listed dinner as it is planned on a day.
export function planDinner(dinner: QuestLogDinner): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
export type SwapSheetProps = {
// The day being swapped.
day: PlanDay
// The ids of the dinners already on the week's plan.
planned: ReadonlySet<string>
onSwap: (swap: Swap) => void
onClose: () => void
}
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
// dinner swaps it into the day; a dinner already this week can't be chosen.
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
const titleId = useId()
const questLog = useQuestLog()
const regulars = useRegulars()
const [source, setSource] = useState<Source>('quest_log')
const lists = { quest_log: questLog, regulars }
const list = lists[source]
const label = (s: Source) => {
const dinners = lists[s].data
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
}
let body
if (list.isError) {
body = (
<ErrorBanner onRetry={() => void list.refetch()}>
Couldn't load your {sourceNames[source]}. {list.error.message}
</ErrorBanner>
)
} else if (list.isPending) {
body = (
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeletonRow} />
))}
</div>
)
} else if (list.data.length === 0) {
body = <p className={styles.empty}>{emptyLists[source]}</p>
} else {
body = (
<ul aria-label={sourceNames[source]} className={styles.rows}>
{list.data.map((dinner: QuestLogDinner) => {
const already = planned.has(dinner.id)
return (
<li key={dinner.id}>
<ListRow
className={already ? styles.already : undefined}
leading={
// The row's name already says what the photo shows.
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
</span>
}
title={dinner.name}
meta={details(dinner)}
// A dinner already this week is no button: it can't be chosen.
onClick={
already
? undefined
: () =>
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
}
trailing={already && <Tag>Already this week</Tag>}
/>
</li>
)
})}
</ul>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<div>
<h2 id={titleId} className={styles.title}>
Swap {weekdayName(day.date)}
</h2>
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
</div>
<Segmented
label="Swap in from"
options={[
{ value: 'quest_log', label: label('quest_log') },
{ value: 'regulars', label: label('regulars') },
]}
value={source}
onValueChange={setSource}
/>
{body}
</div>
</Sheet>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
// changed as given by index.
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
return {
id: 'plan-1',
week_start: WEEK,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: DAYS.map((date, i) => {
const change = changes[i]
return {
date,
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
is_new: change?.is_new ?? false,
start_by: change?.start_by ?? null,
}
}),
}
}
// deferred is a reply the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const reply = new Promise<Response>((resolve) => (send = resolve))
return { reply, send }
}
// stubServer answers "METHOD path" requests from responders, with /me and
// the Quest log; any other request fails the test.
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
const all: Record<string, () => Response | Promise<Response>> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
...responders,
}
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond())
}),
)
}
// renderSwaps renders the week's plan and swaps over a query client.
async function renderSwaps() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient: QueryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
}
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
afterEach(() => {
vi.unstubAllGlobals()
})
// renderPlan renders the week's plan read over a query client.
async function renderPlan() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(() => usePlan(WEEK), { wrapper })
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
return hook.result.current
}
describe('usePlan', () => {
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
const body = planBody()
body.days[2]!.dinner.method = 'crockpot'
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
const plan = await renderPlan()
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
})
it.each([
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
])('refuses a plan with %s', async (_, change, said) => {
const body = planBody()
change(body)
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
const plan = await renderPlan()
expect(plan.error?.message).toContain(said)
})
})
describe('useSwapDay', () => {
// Friday's swap is sent first, then Monday's; the server answers Monday's
// with both swapped, Friday's with only Friday.
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
it.each([
['the later swap answered first', ['monday', 'friday'] as const],
['the earlier swap answered first', ['friday', 'monday'] as const],
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
const replies = { friday: deferred(), monday: deferred() }
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
[`PUT ${MONDAY}`]: () => replies.monday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
// Whichever reply settles, Monday stays swapped: Friday's plan, the
// earlier one, never lands over it.
for (const [settled, day] of order.entries()) {
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
}
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
})
it("does not take an earlier swap's failure for the week's", async () => {
const friday = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
})
const { result, plan } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(result.current.swap.pending).toBe(false))
expect(result.current.swap.failed).toBeNull()
})
it('leaves a day a later plan read landed when the swap of it fails', async () => {
const friday = deferred()
// The read finds Friday already lemon orzo, starting by 2 pm.
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => friday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
await act(() => result.current.plan.refetch())
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
})
it('refuses to dismiss a pending swap', async () => {
const friday = deferred()
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
const { result } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(result.current.swap.pending).toBe(true))
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
await act(async () => friday.send(jsonResponse(200, planBody())))
})
})
+276
View File
@@ -0,0 +1,276 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
export const PLANS_PATH = '/api/v1/plans'
// planPath is the plan of the week starting on Monday weekStart.
export function planPath(weekStart: string): string {
return `${PLANS_PATH}/${encodeURIComponent(weekStart)}`
}
// planKey is the query of the plan of the week starting on weekStart.
export function planKey(weekStart: string) {
return ['plans', weekStart] as const
}
export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
// PlanStatus is draft until Push to Calendar syncs the plan; an edit of a
// synced plan makes it changed.
export type PlanStatus = (typeof PLAN_STATUSES)[number]
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
// photo, minutes its minutes from prep to table and method how it is
// cooked, each when known.
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
// PlanDay is one day of a plan: its household-local date, the dinner
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
// dinner of known minutes, the instant it must start by.
export type PlanDay = {
date: string
dinner: PlanDinner
is_new: boolean
start_by: string | null
}
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
// Google Calendar, if ever. cached says the service worker answered from its
// cache: the server may have changed the plan since.
export type Plan = {
id: string
week_start: string
status: PlanStatus
ai_note: string | null
calendar_synced_at: Date | null
days: PlanDay[]
cached: boolean
}
// DRAFT_JOB_KIND is the kind of the job drafting a week's plan.
export const DRAFT_JOB_KIND = 'draft_week'
// DraftResult is the draft_week job's result: the week drafted and its plan.
export type DraftResult = { week_start: string; plan_id: string }
function parseDay(where: string, body: unknown): PlanDay {
if (
!isRecord(body) ||
typeof body.date !== 'string' ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
typeof body.is_new !== 'boolean' ||
(body.start_by !== null && typeof body.start_by !== 'string')
) {
throw new Error(`${where}: response has a day that is not a plan day`)
}
const dinner: PlanDinner = {
id: body.dinner.id,
name: body.dinner.name,
photo_id: photoId(where, body.dinner.photo),
minutes: body.dinner.minutes as number | null,
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
}
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
}
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
function syncedAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
}
return at
}
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
body.week_start !== weekStart ||
(body.ai_note !== null && typeof body.ai_note !== 'string') ||
!Array.isArray(body.days) ||
body.days.length !== 7
) {
throw new Error(`${where}: response is not the plan of the week of ${weekStart}`)
}
return {
id: body.id,
week_start: weekStart,
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
ai_note: body.ai_note,
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
days: body.days.map((day) => parseDay(where, day)),
cached,
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
if (!isRecord(body) || body.week_start !== weekStart || typeof body.plan_id !== 'string') {
throw new Error(`${where}: result is not the draft of the week of ${weekStart}`)
}
return { week_start: weekStart, plan_id: body.plan_id }
}
// usePlan is the plan of the week starting on Monday weekStart (GET
// /api/v1/plans/{weekStart}), or null when the week has none (404).
export function usePlan(weekStart: string) {
const { apiRead } = useApi()
return useQuery({
queryKey: planKey(weekStart),
queryFn: async (): Promise<Plan | null> => {
const path = planPath(weekStart)
let read: ApiRead<unknown>
try {
read = await apiRead<unknown>(path)
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parsePlan(`GET ${path}`, weekStart, read.body, read.cached)
},
})
}
// useStartDraft asks the AI to draft the week starting on weekStart (POST
// /api/v1/plans/{weekStart}/draft) and resolves with the id of the
// draft_week job, which the server answers with the draft already running
// when there is one. Drafting over a plan pushed to Calendar (synced or
// changed) needs confirm.
export function useStartDraft() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async ({ weekStart, confirm }: { weekStart: string; confirm: boolean }): Promise<string> => {
const path = `${planPath(weekStart)}/draft`
const body = await apiFetch<unknown>(path, { method: 'POST', body: confirm ? { confirm: true } : {} })
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${path}: response has no jobId`)
return body.jobId
},
})
}
// Swap is one day's swap: the day, and the dinner chosen for it with whether
// it was chosen from the Quest log.
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
// sameDay says whether two plan days are the same in every field.
function sameDay(a: PlanDay, b: PlanDay): boolean {
return (
a.date === b.date &&
a.dinner.id === b.dinner.id &&
a.dinner.name === b.dinner.name &&
a.dinner.photo_id === b.dinner.photo_id &&
a.dinner.minutes === b.dinner.minutes &&
a.dinner.method === b.dinner.method &&
a.is_new === b.is_new &&
a.start_by === b.start_by
)
}
// swapKey keys the swaps of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function swapKey(weekStart: string) {
return ['plans', weekStart, 'swap'] as const
}
// SwapDay is the swaps of one week, as the mutation cache holds them:
// whether one is pending, and the failure of the latest, if it failed.
export type SwapDay = {
pending: boolean
failed: { swap: Swap; error: Error } | null
mutate: (chosen: Swap) => void
dismiss: () => void
}
// useSwapDay swaps the dinner of one day of the week starting on weekStart
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
// once, NEW when it came from the Quest log, with no start-by time until the
// server answers with the plan as swapped, which then replaces it. A refused
// swap puts the day back as it was, unless it has changed again meanwhile.
// However the swap ends, the Quest log is read again: the server leaves a
// dinner on the plan out of it.
//
// The week's swaps live in the mutation cache, not in the component, so
// leaving the week and coming back keeps them: pending and failed are read
// from there. Only the latest swap of the week speaks for it: the plan an
// earlier one is answered with is not written over a later one, and its
// failure is not the week's. dismiss leaves settled swaps behind; it is
// refused while one is pending, so a later swap is always in the cache for
// an earlier, pending one to see.
export function useSwapDay(weekStart: string): SwapDay {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = planKey(weekStart)
const mutationKey = swapKey(weekStart)
const mutations = queryClient.getMutationCache()
// The week's swaps, in the order sent.
const swaps = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
})
const latest = swaps.at(-1)
function mutate(chosen: Swap) {
// isLatest says whether no swap of the week was sent after this one.
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
mutationKey,
// Kept until dismissed: a later swap must stay in the cache while an
// earlier one is pending.
gcTime: Infinity,
mutationFn: async ({ date, dinner }) => {
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
return parsePlan(`PUT ${path}`, weekStart, body, false)
},
onMutate: async ({ date, dinner, fromQuestLog }) => {
await queryClient.cancelQueries({ queryKey: key })
const plan = queryClient.getQueryData<Plan | null>(key)
const previous = plan?.days.find((day) => day.date === date)
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
return { previous, swapped }
},
onSuccess: (plan) => {
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
},
// The day goes back only while it is still, field for field, the day
// this swap wrote: a later swap or plan read that replaced it is left
// alone.
onError: (_error, _swap, context) => {
if (context === undefined) return
queryClient.setQueryData<Plan | null>(key, (plan) =>
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
})
// A failure is the mutation's state, which failed reports.
mutation.execute(chosen).catch(() => undefined)
}
function dismiss() {
const week = mutations.findAll({ mutationKey })
if (week.some((mutation) => mutation.state.status === 'pending')) {
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
}
for (const mutation of week) mutations.remove(mutation)
}
return {
pending: swaps.some((swap) => swap.status === 'pending'),
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
mutate,
dismiss,
}
}
+12
View File
@@ -0,0 +1,12 @@
/* The drag's live region: read out, not shown. */
.announcer {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
+353
View File
@@ -0,0 +1,353 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './drag.module.css'
// Pickup is a dinner picked up from the plan panel or the Quest-log strip,
// and whether it is a Quest-log dinner.
export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean }
// Board is the week's grid as a drag drops onto it: its days, whether a
// swap of the week is pending, and how a dinner dropped on a day is swapped
// in.
export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void }
// Drag is the dinner being dragged, by pointer or keyboard, and the date of
// the day it would replace, if any.
export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null }
type PlanDrag = {
drag: Drag | null
board: Board | null
setBoard: (board: Board | null) => void
message: string
// start picks a dinner up, unless it cannot be dropped: there is no grid,
// a swap of the week is pending, or the dinner is already this week. It
// says whether it did.
start: (pickup: Pickup, keyboard: boolean) => boolean
// over moves the drag onto the day of date, or off every day (null).
over: (date: string | null) => void
// step moves a keyboard drag to the previous (-1) or next (1) day.
step: (by: -1 | 1) => void
// end drops the dinner on the day of date, or cancels the drag when date
// is null. It says whether the dinner was dropped.
end: (date: string | null) => boolean
cancel: () => void
}
const PlanDragContext = createContext<PlanDrag | null>(null)
function usePlanDrag(): PlanDrag {
const context = useContext(PlanDragContext)
if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider')
return context
}
// replaces says which dinner dropping on day replaces: "Friday: replaces
// Pizza night."
function replaces(day: PlanDay): string {
return `${weekdayName(day.date)}: replaces ${day.dinner.name}.`
}
// PlanDragProvider holds the drag of a dinner from the plan panel or the
// Quest-log strip onto a day of the week's grid, which the grid registers as
// the Board. Each step of a drag is announced to screen readers.
export function PlanDragProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<Board | null>(null)
const [drag, setDrag] = useState<Drag | null>(null)
const [message, setMessage] = useState('')
// The drag as last set, read by handlers that run before a render: a blur
// the drop's focus move causes must not cancel the drag it just ended.
const current = useRef<Drag | null>(null)
const value = useMemo((): PlanDrag => {
function set(next: Drag | null) {
current.current = next
setDrag(next)
}
function dayOf(date: string | null): PlanDay | undefined {
return board?.days.find((day) => day.date === date)
}
function cancel() {
const now = current.current
if (now === null) return
set(null)
setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`)
}
return {
drag,
board,
setBoard,
message,
start(pickup, keyboard) {
const { name } = pickup.dinner
if (board === null) {
setMessage(`There is no plan this week to swap ${name} into.`)
return false
}
if (board.pending) {
setMessage(`A swap is still being saved. Pick ${name} up once it is done.`)
return false
}
if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) {
setMessage(`${name} is already this week.`)
return false
}
// A keyboard drag starts on Monday; a pointer drag on no day.
const first = board.days[0]!
set({ pickup, keyboard, date: keyboard ? first.date : null })
setMessage(
keyboard
? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.`
: `Picked up ${name}.`,
)
return true
},
over(date) {
const now = current.current
if (now === null || now.date === date) return
set({ ...now, date })
},
step(by) {
const now = current.current
if (now === null || board === null) return
const at = board.days.findIndex((day) => day.date === now.date)
const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]!
set({ ...now, date: next.date })
setMessage(replaces(next))
},
end(date) {
const now = current.current
if (now === null) return false
const day = dayOf(date)
if (day === undefined || board === null) {
cancel()
return false
}
set(null)
if (board.pending) {
setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`)
return false
}
board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog })
setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`)
return true
},
cancel,
}
}, [board, drag, message])
return <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
}
// DragAnnouncer is the polite live region a drag's steps are announced in.
export function DragAnnouncer() {
const { message } = usePlanDrag()
return (
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
{message}
</p>
)
}
// DropBoard registers the week's grid as the Board while it is shown.
export function DropBoard({ days, pending, drop }: Board) {
const { setBoard } = usePlanDrag()
// drop is a new function on every render: the Board calls the latest.
const latestDrop = useRef(drop)
useLayoutEffect(() => {
latestDrop.current = drop
})
useLayoutEffect(() => {
setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) })
return () => setBoard(null)
}, [setBoard, days, pending])
return null
}
// useDropTarget is the drag over the day of date, if it is over that day.
export function useDropTarget(date: string): Drag | null {
const { drag } = usePlanDrag()
return drag !== null && drag.date === date ? drag : null
}
// DROP_DATE is the attribute naming the date of a day a dinner drops on.
export const DROP_DATE = 'data-drop-date'
// dateAt is the date of the day under the point (x, y), or null when it is
// over no day.
function dateAt(x: number, y: number): string | null {
return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null
}
// A press becomes a drag once the pointer has moved this far, so a tap is
// not one.
const DRAG_THRESHOLD = 4
// The dragged copy floats this far below and to the right of the pointer,
// as a drag image does, so it stays clear of the "Drop to swap" text
// centred in the day the pointer is over. Where it would leave the
// viewport it flips to the left of the pointer, or above it.
const FLOAT_OFFSET = 48
// Press is a pointer held on a draggable: its id, where it went down, the
// draggable's size, and whether it has become a drag.
type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean }
// Float is where the dragged copy of a draggable is drawn, and how wide.
export type Float = { left: number; top: number; width: number }
// floatAt is where the dragged copy of the held draggable is drawn for the
// pointer at (x, y): offset below right of it, flipped where that would
// leave the viewport.
function floatAt(held: Press, x: number, y: number): Float {
const right = x + FLOAT_OFFSET
const below = y + FLOAT_OFFSET
return {
left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right,
top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below,
width: held.width,
}
}
// useDraggable makes a panel row or a strip chip a draggable dinner. With a
// pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it
// up, its copy floats with the pointer, and letting go over a day drops it
// there, anywhere else cancels. With the keyboard Enter or Space picks it
// up onto Monday, the arrow keys choose the day, Enter drops it and
// Escape, or leaving it, cancels; a drop moves focus to the day's swap
// button, as a Quest-log dinner leaves the Quest log once planned.
export function useDraggable(pickup: Pickup) {
const plan = usePlanDrag()
const [float, setFloat] = useState<Float | null>(null)
const press = useRef<Press | null>(null)
const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null
const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id))
// A draggable that goes mid-drag, as on crossing to mobile, cancels it;
// once the drag has ended, cancel does nothing.
const { cancel } = plan
const dragging = drag !== null
const latestCancel = useRef(cancel)
useLayoutEffect(() => {
latestCancel.current = cancel
})
useEffect(() => {
if (!dragging) return
return () => latestCancel.current()
}, [dragging])
function release() {
press.current = null
setFloat(null)
}
function onPointerDown(event: PointerEvent<HTMLElement>) {
if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.setPointerCapture(event.pointerId)
press.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
width: rect.width,
height: rect.height,
dragging: false,
}
}
function onPointerMove(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
if (!held.dragging) {
if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return
if (!plan.start(pickup, false)) {
press.current = null
return
}
held.dragging = true
}
setFloat(floatAt(held, event.clientX, event.clientY))
plan.over(dateAt(event.clientX, event.clientY))
}
function onPointerUp(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.end(dateAt(event.clientX, event.clientY))
}
// A cancel, or losing the capture before the release, cancels the drag.
// After a release the press is already over, so this ends only a drag
// whose capture was lost before it.
function onPointerCancel(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.cancel()
}
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
const button = event.currentTarget
if (drag === null) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (plan.drag === null) plan.start(pickup, true)
return
}
if (event.key === 'Escape') {
event.preventDefault()
release()
plan.cancel()
return
}
if (!drag.keyboard) return
// Space picked it up and does not drop it; it does not scroll the page.
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1)
else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1)
else if (event.key === 'Enter') drop(button.ownerDocument, drag.date)
else if (event.key !== ' ') return
event.preventDefault()
}
// drop ends a keyboard drag on the day of date and, once dropped, moves
// focus to that day's swap button.
function drop(document: Document, date: string | null) {
if (!plan.end(date)) return
const swapButton = document.querySelector<HTMLElement>(`[${DROP_DATE}="${date}"] button`)
if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`)
swapButton.focus()
}
// Leaving a keyboard drag cancels it.
function onBlur() {
if (drag !== null && drag.keyboard) plan.cancel()
}
return {
drag,
float,
props: {
'aria-disabled': disabled || undefined,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture: onPointerCancel,
onKeyDown,
onBlur,
},
}
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
describe('week helpers', () => {
it('finds the Monday of a week and moves by days across months and years', () => {
expect(weekStart('2026-10-07')).toBe('2026-10-05')
expect(weekStart('2026-10-05')).toBe('2026-10-05')
expect(weekStart('2026-10-11')).toBe('2026-10-05')
expect(addDays('2026-10-05', 7)).toBe('2026-10-12')
expect(addDays('2026-10-05', -7)).toBe('2026-09-28')
expect(addDays('2026-12-28', 7)).toBe('2027-01-04')
})
it('crosses a DST change without losing a day', () => {
expect(addDays('2026-10-26', 7)).toBe('2026-11-02')
expect(addDays('2027-03-08', 7)).toBe('2027-03-15')
})
it('labels weeks as the API does, by ISO week', () => {
expect(weekLabel('2026-10-05')).toEqual({ week: 'Week 41', dates: 'Oct 5 – 11' })
expect(weekLabel('2026-09-28')).toEqual({ week: 'Week 40', dates: 'Sep 28 – Oct 4' })
expect(weekLabel('2026-12-28')).toEqual({ week: 'Week 53', dates: 'Dec 28 – Jan 3' })
expect(weekLabel('2027-01-04')).toEqual({ week: 'Week 1', dates: 'Jan 4 – 10' })
})
it('names the day of a date as a tile kicker', () => {
expect(weekdayKicker('2026-10-05')).toBe('MON')
expect(weekdayKicker('2026-10-11')).toBe('SUN')
})
it('names the day of a date in full', () => {
expect(weekdayName('2026-10-09')).toBe('Friday')
expect(weekdayName('2026-10-11')).toBe('Sunday')
})
it('reads the day of the month of a date', () => {
expect(dayOfMonth('2026-10-05')).toBe(5)
expect(dayOfMonth('2026-10-31')).toBe(31)
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
})
it('refuses a week start that is not a Monday, and a date that is not real', () => {
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
expect(() => weekStart('Oct 5')).toThrow('not a YYYY-MM-DD date')
})
it('reads the household-local date and clock in its zone', () => {
// 03:30 UTC on Thursday is still Wednesday evening in New York.
expect(localNow('America/New_York', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-07', time: '23:30' })
expect(localNow('Asia/Tokyo', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-08', time: '12:30' })
})
it('opens on this week until the plan_draft time, then on next week', () => {
expect(defaultWeek(SCHEDULE, new Date('2026-10-07T16:00:00Z'))).toBe('2026-10-05') // Wed noon
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T12:59:00Z'))).toBe('2026-10-05') // Fri 08:59
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T13:00:00Z'))).toBe('2026-10-12') // Fri 09:00
expect(defaultWeek(SCHEDULE, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-12') // Sat
expect(defaultWeek(SCHEDULE, new Date('2026-10-12T04:30:00Z'))).toBe('2026-10-12') // the next Mon, 00:30
})
it('counts a Sunday plan_draft day as the end of the Monday-start week', () => {
const sunday = { ...SCHEDULE, plan_draft_dow: 0, plan_draft_time: '18:00' }
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
})
})
+110
View File
@@ -0,0 +1,110 @@
// Household-local calendar helpers for the weekly plan, mirroring
// internal/household/week.go. Weeks are ISO weeks, Monday to Sunday. Dates
// are household-local YYYY-MM-DD strings; arithmetic is done on UTC
// midnights, which have no DST, so a date plus seven days is always the same
// weekday a week later.
const localDate = /^(\d{4})-(\d{2})-(\d{2})$/
const DAY_MS = 24 * 60 * 60 * 1000
const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const
// midnightUTC reads date as midnight UTC, or throws when it is not a real
// YYYY-MM-DD date.
function midnightUTC(date: string): Date {
const match = localDate.exec(date)
if (match === null) throw new Error(`not a YYYY-MM-DD date: "${date}"`)
const t = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
if (formatDate(t) !== date) throw new Error(`not a real date: "${date}"`)
return t
}
function formatDate(t: Date): string {
return t.toISOString().slice(0, 10)
}
// addDays returns date moved by days, which may be negative.
export function addDays(date: string, days: number): string {
return formatDate(new Date(midnightUTC(date).getTime() + days * DAY_MS))
}
// weekStart returns the Monday of the week containing date.
export function weekStart(date: string): string {
const sinceMonday = (midnightUTC(date).getUTCDay() + 6) % 7
return addDays(date, -sinceMonday)
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
}
// dayOfMonth is date's day of the month: 1 to 31.
export function dayOfMonth(date: string): number {
return midnightUTC(date).getUTCDate()
}
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
// weekdayName is date's day in full: "Monday" to "Sunday".
export function weekdayName(date: string): string {
return weekday.format(midnightUTC(date))
}
// monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date {
const t = midnightUTC(start)
if (t.getUTCDay() !== 1) throw new Error(`week start ${start} is not a Monday`)
return t
}
// isoWeek is the ISO 8601 week number of the week starting on Monday start:
// the week holding that week's Thursday's year's first Thursday counts as 1.
function isoWeek(start: Date): number {
const thursday = new Date(start.getTime() + 3 * DAY_MS)
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1
}
const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
// WeekLabel is the week starting on a Monday as the plan's header shows it:
// "Week 41" over "Oct 5 – 11", or "Sep 28 – Oct 4" when the week spans two
// months. Together they are the API's week_label.
export type WeekLabel = { week: string; dates: string }
// weekLabel labels the week starting on Monday start.
export function weekLabel(start: string): WeekLabel {
const first = monday(start)
const last = new Date(first.getTime() + 6 * DAY_MS)
const end = last.getUTCMonth() === first.getUTCMonth() ? String(last.getUTCDate()) : monthDay.format(last)
return { week: `Week ${isoWeek(first)}`, dates: `${monthDay.format(first)} – ${end}` }
}
// localNow is the household-local date (YYYY-MM-DD) and clock (24-hour
// HH:MM) at instant now in IANA zone.
export function localNow(zone: string, now: Date): { date: string; time: string } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value
return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
}
// defaultWeek is the Monday of the week the plan opens on: next week once
// this week's plan_draft time (day of the week counting from Sunday = 0,
// 24-hour HH:MM, both household-local) has come, otherwise this week.
export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number; plan_draft_time: string }, now: Date): string {
const today = localNow(schedule.timezone, now)
const thisWeek = weekStart(today.date)
const draftDay = addDays(thisWeek, (schedule.plan_draft_dow + 6) % 7)
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek
}
+5
View File
@@ -19,3 +19,8 @@
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
.neutral {
background: var(--color-neutral-100);
color: var(--color-neutral-800);
}
+5
View File
@@ -15,4 +15,9 @@ describe('Tag', () => {
render(<Tag tone="teal">Synced</Tag>)
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
})
it('supports the neutral tone', () => {
render(<Tag tone="neutral">Regular</Tag>)
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
})
})
+3 -2
View File
@@ -1,13 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal'
export type TagTone = 'grape' | 'teal' | 'neutral'
export type TagProps = ComponentPropsWithRef<'span'> & {
tone?: TagTone
}
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
// Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
// interactive.
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} />