Add plan service and weekly plan API
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m1s

Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 06:18:09 -04:00
co-authored by Claude Opus 5.5
parent 09456c48a2
commit 75d6ad4e19
20 changed files with 2201 additions and 20 deletions
+344
View File
@@ -0,0 +1,344 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: plans.sql
package db
import (
"context"
"database/sql"
)
const createWeekPlan = `-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?)
`
type CreateWeekPlanParams struct {
ID string
WeekStart string
AiNote sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, createWeekPlan,
arg.ID,
arg.WeekStart,
arg.AiNote,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const getActiveDraftJobID = `-- name: GetActiveDraftJobID :one
SELECT id FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.week_start') = ?2
ORDER BY id
LIMIT 1
`
type GetActiveDraftJobIDParams struct {
Kind string
WeekStart string
}
// The id of the job of kind queued or running for the week starting
// week_start. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetActiveDraftJobID(ctx context.Context, arg GetActiveDraftJobIDParams) (string, error) {
row := q.db.QueryRowContext(ctx, getActiveDraftJobID, arg.Kind, arg.WeekStart)
var id string
err := row.Scan(&id)
return id, err
}
const getWeekPlan = `-- name: GetWeekPlan :one
SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at FROM week_plans
WHERE week_start = ?
`
// The plan of the week starting week_start. Returns sql.ErrNoRows when
// there is none.
func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) {
row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart)
var i WeekPlan
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.Status,
&i.AiNote,
&i.AiRunID,
&i.CalendarSyncedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listDraftPool = `-- name: ListDraftPool :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status = 'regular'
OR (status = 'quest_log' AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= ?1
AND week_plans.week_start <> ?2
))
ORDER BY normalized_name, id
`
type ListDraftPoolParams struct {
FromWeekStart string
WeekStart string
}
// The dinners a draft of the week starting week_start may plan: every
// Regular, and every Quest log dinner no other plan for the week starting
// from_week_start or later has on any day.
func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlanDays = `-- name: ListPlanDays :many
SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
ORDER BY plan_days.date
`
type ListPlanDaysRow struct {
Date string
IsNew int64
Dinner Dinner
}
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) {
rows, err := q.db.QueryContext(ctx, listPlanDays, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlanDaysRow
for rows.Next() {
var i ListPlanDaysRow
if err := rows.Scan(
&i.Date,
&i.IsNew,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many
SELECT plan_days.dinner_id
FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
ORDER BY plan_days.date
`
// The ids of the dinners planned in the week starting week_start, Monday
// first.
func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) {
rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []sql.NullString
for rows.Next() {
var dinner_id sql.NullString
if err := rows.Scan(&dinner_id); err != nil {
return nil, err
}
items = append(items, dinner_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
updated_at = ?
WHERE id = ?
`
type MarkWeekPlanEditedParams struct {
UpdatedAt string
ID string
}
// Records an edit of plan id: a synced plan becomes changed, any other
// keeps its status.
func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error {
_, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID)
return err
}
const putPlanDay = `-- name: PutPlanDay :exec
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at
`
type PutPlanDayParams struct {
ID string
PlanID string
Date string
DinnerID sql.NullString
IsNew int64
CreatedAt string
UpdatedAt string
}
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
// when the day exists.
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
_, err := q.db.ExecContext(ctx, putPlanDay,
arg.ID,
arg.PlanID,
arg.Date,
arg.DinnerID,
arg.IsNew,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const redraftWeekPlan = `-- name: RedraftWeekPlan :exec
UPDATE week_plans
SET status = ?, ai_note = ?, updated_at = ?
WHERE id = ?
`
type RedraftWeekPlanParams struct {
Status string
AiNote sql.NullString
UpdatedAt string
ID string
}
// Records a new draft over plan id: its status and note.
func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, redraftWeekPlan,
arg.Status,
arg.AiNote,
arg.UpdatedAt,
arg.ID,
)
return err
}
const swapPlanDay = `-- name: SwapPlanDay :execrows
UPDATE plan_days
SET dinner_id = ?, is_new = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SwapPlanDayParams struct {
DinnerID sql.NullString
IsNew int64
UpdatedAt string
PlanID string
Date string
}
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
result, err := q.db.ExecContext(ctx, swapPlanDay,
arg.DinnerID,
arg.IsNew,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+28
View File
@@ -38,6 +38,7 @@ type Querier interface {
// 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 suggestion; one already
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
@@ -67,6 +68,9 @@ type Querier interface {
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The id of the job of kind queued or running for the week starting
// week_start. Returns sql.ErrNoRows when there is none.
GetActiveDraftJobID(ctx context.Context, arg GetActiveDraftJobIDParams) (string, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -93,6 +97,9 @@ type Querier interface {
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// The plan of the week starting week_start. Returns sql.ErrNoRows when
// there is none.
GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
@@ -116,6 +123,13 @@ type Querier interface {
// 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)
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
@@ -131,18 +145,32 @@ type Querier interface {
// The Quest log: dinners with status quest_log that no plan for the week
// starting from_week_start or later has on any day.
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
// The ids of the dinners planned in the week starting week_start, Monday
// first.
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
// Records an edit of plan id: a synced plan becomes changed, any other
// keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (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
// when the day exists.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Records a new draft over plan id: its status and note.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.