Add the Rotation check: MarkTried and keep/drop decisions
CI / ci (push) Successful in 3m28s
CI / ci (pull_request) Successful in 3m7s

Per D-021, plan.MarkTried(date) moves every Quest log dinner planned on
a household-local date (any plan status) to tried and opens a pending
rotation_reviews row; a dinner already checked for the date is left as
it is, so it is idempotent and never re-marks a dinner returned to the
Quest log.

GET /api/v1/rotation-reviews lists the pending checks as
{dinner {id, name, photo}, plan_date}. POST
/api/v1/dinners/{id}/rotation-review {decision} applies keep (Regular,
origin kept), drop ("Not for us", archived) or not_made ("We didn't
make it", back to the Quest log), closing the check with decided_at and
the session's updated_by; a second decision is 409 no_pending_review.

A dropped dinner's name joins the passed names: ListDeclinedSuggestionNames
and ListRecentDeclinedNames become ListPassedNames and
ListRecentPassedNames, which also read Rotation checks decided drop, so
the Side quest dedupe and household context never suggest it again.

A static test guards that keep is the only path to Regular: every query
writing dinners.status and every use of the regular status is reviewed.

Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 07:14:01 -04:00
co-authored by Claude Opus 5.5
parent 2ed5f69783
commit f97b4c7b8c
19 changed files with 1234 additions and 54 deletions
+20
View File
@@ -364,6 +364,26 @@ automatic** (D-021).
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, and any other dinner 409 `dinner_not_plannable`.
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
`rotation_check` schedule calls for the household-local day just past,
moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
a dinner already checked for that date is left as it is, so it is
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
pending checks whose dinner is still tried, oldest `plan_date` first,
each `{dinner: {id, name, photo}, plan_date}`. `POST
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
("We didn't make it") returns it to the Quest log; its origin is kept.
The check is closed with `decided_at` and the session's `updated_by`,
and the answer is `{dinner, plan_date, decision, dinner_status,
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
an unknown dinner 404, a dinner with no pending check (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
dropped dinner's name joins the passed names (`ListPassedNames`) the
Side quest dedupe and household context use, so it is never suggested
again.
- `/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
}
+15 -8
View File
@@ -52,20 +52,27 @@ 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 dinners.name, rotation_reviews.decided_at, rotation_reviews.id
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.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
}
+29 -6
View File
@@ -34,11 +34,15 @@ 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; 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 +91,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 +129,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 of each dinner a Rotation check dropped
// ("Not for us"), which archiving keeps.
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 +149,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 +166,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 +176,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
+240
View File
@@ -0,0 +1,240 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: rotation_reviews.sql
package db
import (
"context"
"database/sql"
)
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,
decided_at = ?2,
updated_by = ?3,
updated_at = ?2
WHERE id = ?4 AND status = 'pending'
`
type DecideRotationReviewParams struct {
Decision sql.NullString
Now sql.NullString
UpdatedBy sql.NullString
ID string
}
// Records the household's decision on a pending Rotation check; 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.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, 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.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()
}
+17 -8
View File
@@ -348,17 +348,26 @@ 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 dinners.name, rotation_reviews.decided_at, rotation_reviews.id
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.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 of each dinner a Rotation check dropped
// ("Not for us"), which archiving keeps.
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPassedNames)
if err != nil {
return nil, err
}
+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 (
+209
View File
@@ -0,0 +1,209 @@
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,
}
// ErrNoPendingReview is a decision on a dinner with no pending Rotation
// check: it was never tried, or its check is already decided.
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. An unknown dinner is ErrDinnerNotFound, a dinner with no pending
// check (including 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) {
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},
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
}
+373
View File
@@ -0,0 +1,373 @@
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, 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, 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.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].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)
}
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 {
t.Errorf("reviews = %+v, want one decided %s by %s at %s", 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)
}
wantPassed := tc.decision == plan.DecisionDrop
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.ErrNoPendingReview) {
t.Errorf("dinner never tried: %v, want ErrNoPendingReview", 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)
}
}
}
+2 -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) {
+85
View File
@@ -0,0 +1,85 @@
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
// 404, and a dinner with no pending check (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.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")
}
}
+129
View File
@@ -0,0 +1,129 @@
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)
}
})
}
}
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))
+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 {
+1 -1
View File
@@ -646,7 +646,7 @@ func TestDeclineIsIdempotent(t *testing.T) {
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
t.Errorf("dinner status after refused accept = %s, want archived", got)
}
names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context())
names, err := db.New(e.st.Reader).ListPassedNames(t.Context())
if err != nil {
t.Fatal(err)
}
+13 -6
View File
@@ -1,11 +1,18 @@
-- 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 dinners.name, rotation_reviews.decided_at, rotation_reviews.id
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.decision = 'drop'
)
ORDER BY decided_at DESC, id DESC
LIMIT ?;
+60
View File
@@ -0,0 +1,60 @@
-- 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: DecideRotationReview :execrows
-- Records the household's decision on a pending Rotation check; one
-- already decided is left as it is (0 rows).
UPDATE rotation_reviews
SET status = 'decided',
decision = sqlc.arg(decision),
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';
+15 -6
View File
@@ -86,12 +86,21 @@ 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 of each dinner a Rotation check dropped
-- ("Not for us"), which archiving keeps.
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT dinners.name, rotation_reviews.decided_at, rotation_reviews.id
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.decision = 'drop'
)
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows