Add plan service and weekly plan API
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:
+21
-2
@@ -23,6 +23,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -112,6 +113,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -128,7 +134,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -253,7 +259,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// registerJobKinds registers the framework's job kinds on runner. A feature
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch).
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -286,6 +293,18 @@ func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester
|
||||
}
|
||||
}
|
||||
|
||||
// weekDrafter runs draft_week with the AI settings current at each run,
|
||||
// sharing guard.
|
||||
func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
|
||||
return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.Week{}, err
|
||||
}
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -334,6 +334,31 @@ automatic** (D-021).
|
||||
the decision's own transaction, so a decision is never recorded without
|
||||
the replenish it calls for; when they cannot be written, neither is the
|
||||
decision, and a retry decides again.
|
||||
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
|
||||
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
|
||||
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
|
||||
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
|
||||
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
|
||||
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
|
||||
the dinner was planned from the Quest log; `start_by` is the household's
|
||||
dinner time on that date minus `minutes_total`, computed on read, for a
|
||||
crock-pot dinner with known minutes, else null. `POST
|
||||
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
|
||||
`{job_id}` for a `draft_week` job, or for the one already queued or
|
||||
running for the week. The job runs the `draft_week` skill on every Regular
|
||||
and every Quest log dinner no other plan for the current or a later week
|
||||
has, with the household context and last week's dinners, and stores the
|
||||
week: a new plan is `draft`; a redraft keeps each day's
|
||||
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
|
||||
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
|
||||
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
|
||||
left as it is and the job fails `confirm_required`. 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`.
|
||||
- `/health` — liveness.
|
||||
- Everything else serves the embedded PWA with SPA fallback.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
|
||||
// the household confirmed drafting over a synced or changed plan.
|
||||
type DraftJob struct {
|
||||
WeekStart string `json:"week_start"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// draftResult is the KindDraftWeek result: the week drafted and its plan.
|
||||
type draftResult struct {
|
||||
WeekStart string `json:"week_start"`
|
||||
PlanID string `json:"plan_id"`
|
||||
}
|
||||
|
||||
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runDraft drafts the week p.WeekStart: draft_week plans it from the
|
||||
// Regulars and the Quest log dinners no other current or later plan has,
|
||||
// given the household context and last week's dinners, and the draft is
|
||||
// stored as the week's plan. A pool under seven dinners fails with
|
||||
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
|
||||
// week is made up instead.
|
||||
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
|
||||
weekStart, err := ParseWeekStart(p.WeekStart)
|
||||
if err != nil {
|
||||
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
|
||||
}
|
||||
in, err := s.request(ctx, weekStart)
|
||||
if err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return draftResult{}, ctx.Err()
|
||||
}
|
||||
var few *skills.NotEnoughDinnersError
|
||||
if errors.As(err, &few) {
|
||||
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
|
||||
}
|
||||
// Only the code is logged: the error may quote a model's response.
|
||||
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
|
||||
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
for _, w := range week.Warnings {
|
||||
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
|
||||
}
|
||||
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, week)
|
||||
if err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
|
||||
}
|
||||
|
||||
// request reads what draft_week is given for the week starting weekStart:
|
||||
// the household context, the pool, last week's dinners and the dinner
|
||||
// time.
|
||||
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Reader)
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
thisWeek := household.WeekStart(hctx.Today)
|
||||
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
|
||||
}
|
||||
pool := make([]skills.Candidate, len(rows))
|
||||
for i, d := range rows {
|
||||
kind := skills.KindRegular
|
||||
if d.Status == dinners.StatusQuestLog {
|
||||
kind = skills.KindQuestLog
|
||||
}
|
||||
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
|
||||
}
|
||||
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
|
||||
}
|
||||
lastWeek := []string{}
|
||||
for _, id := range last {
|
||||
lastWeek = append(lastWeek, id.String)
|
||||
}
|
||||
return skills.WeekRequest{
|
||||
Context: hctx,
|
||||
WeekStart: weekStart,
|
||||
Pool: pool,
|
||||
LastWeek: lastWeek,
|
||||
DinnerTime: settings.DinnerTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// storeDraft stores week as the plan of the week starting weekStart in one
|
||||
// transaction and returns the plan's id. A new plan is draft. A draft
|
||||
// replaces the days of an existing plan, keeping each day's calendar
|
||||
// event id; a draft plan stays draft and a synced or changed one becomes
|
||||
// changed, since Calendar no longer shows it. A plan synced since the
|
||||
// draft was asked for without confirm is left as it is, failing with
|
||||
// ReasonConfirmRequired.
|
||||
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, week skills.Week) (string, error) {
|
||||
var planID string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
now := store.Now().String()
|
||||
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
planID = store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
|
||||
ID: planID,
|
||||
WeekStart: weekStart.String(),
|
||||
AiNote: note,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
|
||||
}
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
case cur.Status != StatusDraft && !confirm:
|
||||
return &jobs.Failure{Code: ReasonConfirmRequired}
|
||||
default:
|
||||
planID = cur.ID
|
||||
status := StatusDraft
|
||||
if cur.Status != StatusDraft {
|
||||
status = StatusChanged
|
||||
}
|
||||
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
|
||||
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
|
||||
}
|
||||
}
|
||||
for _, d := range week.Days {
|
||||
isNew := int64(0)
|
||||
if d.IsNew {
|
||||
isNew = 1
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
|
||||
ID: store.NewID(),
|
||||
PlanID: planID,
|
||||
Date: d.Date,
|
||||
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
|
||||
IsNew: isNew,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
// addDays returns d plus n days.
|
||||
func addDays(d store.LocalDate, n int) store.LocalDate {
|
||||
y, m, day := d.Date()
|
||||
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Package plan keeps the household's weekly plans: the Draft board the AI
|
||||
// drafts from the Regulars and the Quest log, and the swaps the household
|
||||
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
|
||||
// a day from Monday to Sunday, and the job stores them as the week's plan,
|
||||
// replacing any draft before it. A plan is draft until Push to Calendar
|
||||
// syncs it (Feature 16); an edit of a synced plan makes it changed.
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindDraftWeek is the job that drafts a week's plan.
|
||||
const KindDraftWeek = "draft_week"
|
||||
|
||||
// Plan statuses (the week_plans.status column).
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusSynced = "synced"
|
||||
StatusChanged = "changed"
|
||||
)
|
||||
|
||||
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
|
||||
// Any other failure, such as the database's, is the runner's
|
||||
// internal_error.
|
||||
const (
|
||||
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
|
||||
// seven dinners to draft from.
|
||||
ReasonNotEnoughDinners = "not_enough_dinners"
|
||||
// ReasonAIFailed: draft_week failed; no week is made up instead.
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonConfirmRequired: the plan was synced after the draft was
|
||||
// asked for without confirm, so the draft does not replace it.
|
||||
ReasonConfirmRequired = "confirm_required"
|
||||
)
|
||||
|
||||
// Reasons are every draft failure reason.
|
||||
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired}
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// draft_week is given (see harness.BuildContext).
|
||||
const ContextBudget = 16000
|
||||
|
||||
// plannable are the dinner statuses a day may be swapped to.
|
||||
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
|
||||
|
||||
// ErrNotFound is returned for a week with no plan.
|
||||
var ErrNotFound = errors.New("plan not found")
|
||||
|
||||
// ErrDinnerNotFound is a swap to an unknown dinner.
|
||||
var ErrDinnerNotFound = errors.New("dinner not found")
|
||||
|
||||
// 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")
|
||||
|
||||
// InvalidWeekError is a week start that is not a Monday date.
|
||||
type InvalidWeekError struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (e *InvalidWeekError) Error() string {
|
||||
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
|
||||
}
|
||||
|
||||
// InvalidDayError is a swap of a date outside the plan's week.
|
||||
type InvalidDayError struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (e *InvalidDayError) Error() string {
|
||||
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
|
||||
}
|
||||
|
||||
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
|
||||
// nor in the Quest log.
|
||||
type DinnerNotPlannableError struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e *DinnerNotPlannableError) Error() string {
|
||||
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
|
||||
}
|
||||
|
||||
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
|
||||
func ParseWeekStart(s string) (store.LocalDate, error) {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||
}
|
||||
if _, err := household.WeekDays(d); err != nil {
|
||||
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Drafter runs draft_week on in; scope names the week. In production it is
|
||||
// skills.DraftWeek with the household's harness.Config.
|
||||
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
|
||||
|
||||
// Service drafts, reads and edits weekly plans.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
draft Drafter
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft and
|
||||
// registers the KindDraftWeek job on runner, which must not have started
|
||||
// yet.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
case draft == nil:
|
||||
return nil, errors.New("plan: nil drafter")
|
||||
case runner == nil:
|
||||
return nil, errors.New("plan: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.draftJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Plan is a week's plan as the API shows it.
|
||||
type Plan struct {
|
||||
ID string `json:"id"`
|
||||
WeekStart string `json:"week_start"`
|
||||
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
|
||||
WeekLabel string `json:"week_label"`
|
||||
Status string `json:"status"`
|
||||
AINote *string `json:"ai_note"`
|
||||
CalendarSyncedAt *string `json:"calendar_synced_at"`
|
||||
Days []Day `json:"days"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Day is one day of a plan.
|
||||
type Day struct {
|
||||
// Date is household-local, YYYY-MM-DD.
|
||||
Date string `json:"date"`
|
||||
Dinner Dinner `json:"dinner"`
|
||||
// IsNew is true for a dinner planned from the Quest log.
|
||||
IsNew bool `json:"is_new"`
|
||||
// StartBy is when a crock-pot dinner must start to be ready at the
|
||||
// household's dinner time; null for any other dinner and for a
|
||||
// crock-pot dinner whose minutes are unknown.
|
||||
StartBy *time.Time `json:"start_by"`
|
||||
}
|
||||
|
||||
// Dinner is a planned dinner as a plan day shows it.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
Minutes *int64 `json:"minutes"`
|
||||
Method *string `json:"method"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
// Get returns the plan of the week starting weekStart, or ErrNotFound.
|
||||
// The plan and the household settings come from one read transaction.
|
||||
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
return readPlan(ctx, db.New(tx), weekStart)
|
||||
}
|
||||
|
||||
// readPlan reads the plan of the week starting weekStart through q, with
|
||||
// each day's start_by from the household's dinner time and zone.
|
||||
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
|
||||
label, err := household.WeekLabel(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
row, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Plan{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
rows, err := q.ListPlanDays(ctx, row.ID)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
|
||||
}
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
if len(rows) != len(dates) {
|
||||
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
|
||||
}
|
||||
p := Plan{
|
||||
ID: row.ID,
|
||||
WeekStart: row.WeekStart,
|
||||
WeekLabel: label,
|
||||
Status: row.Status,
|
||||
AINote: nullString(row.AiNote),
|
||||
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
|
||||
Days: make([]Day, len(rows)),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
for i, r := range rows {
|
||||
if r.Date != dates[i].String() {
|
||||
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
|
||||
}
|
||||
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
|
||||
}
|
||||
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// startBy returns when crock-pot dinner d must start on date to be ready at
|
||||
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
|
||||
// dinner whose minutes are unknown.
|
||||
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
|
||||
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
at, err := household.LocalTime(zone, date, dinnerTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
|
||||
return &at, nil
|
||||
}
|
||||
|
||||
// Draft is a draft asked for: the job drafting the week, and whether this
|
||||
// call enqueued it rather than found one already queued or running.
|
||||
type Draft struct {
|
||||
JobID string `json:"job_id"`
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartDraft enqueues the KindDraftWeek job drafting the week starting
|
||||
// weekStart, unless one is already queued or running for it, which it
|
||||
// returns instead with Created false. Drafting over a synced or changed
|
||||
// plan needs confirm, else it fails with ErrConfirmRequired and enqueues
|
||||
// nothing.
|
||||
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
var d Draft
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
case cur.Status != StatusDraft && !confirm:
|
||||
return ErrConfirmRequired
|
||||
}
|
||||
id, err := q.GetActiveDraftJobID(ctx, db.GetActiveDraftJobIDParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
|
||||
if err == nil {
|
||||
d = Draft{JobID: id}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
|
||||
}
|
||||
id, err = s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
|
||||
}
|
||||
d = Draft{JobID: id, Created: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
if d.Created {
|
||||
s.draftJob.Wake()
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||
// day new when the dinner is in the Quest log. Swapping a synced plan
|
||||
// makes it changed. The week must have a plan (else ErrNotFound), date
|
||||
// must be one of its days (else *InvalidDayError), and the dinner must be
|
||||
// a Regular or in the Quest log (else ErrDinnerNotFound or
|
||||
// *DinnerNotPlannableError). It returns the plan as swapped.
|
||||
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
day, err := store.ParseLocalDate(date)
|
||||
if err != nil || !slices.Contains(dates[:], day) {
|
||||
return Plan{}, &InvalidDayError{Value: date}
|
||||
}
|
||||
var p Plan
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
}
|
||||
d, err := q.GetDinner(ctx, dinnerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrDinnerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
||||
}
|
||||
if !slices.Contains(plannable, d.Status) {
|
||||
return &DinnerNotPlannableError{Status: d.Status}
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true},
|
||||
IsNew: isNew(d.Status),
|
||||
UpdatedAt: now,
|
||||
PlanID: cur.ID,
|
||||
Date: day.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
|
||||
}
|
||||
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
|
||||
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
|
||||
}
|
||||
p, err = readPlan(ctx, q, weekStart)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||
func isNew(status string) int64 {
|
||||
if status == dinners.StatusQuestLog {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fromDinner(d db.Dinner) Dinner {
|
||||
out := Dinner{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Method: nullString(d.Method),
|
||||
Origin: d.Origin,
|
||||
}
|
||||
if d.MinutesTotal.Valid {
|
||||
out.Minutes = &d.MinutesTotal.Int64
|
||||
}
|
||||
if d.PhotoID.Valid {
|
||||
u := dinners.PhotoURL(d.PhotoID.String)
|
||||
out.Photo = &u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package plan_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// week is the Monday the tests draft, and weekDates its days.
|
||||
var (
|
||||
week = mustDate("2026-10-12")
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type env struct {
|
||||
svc *plan.Service
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
model *llm.Fake
|
||||
// ids are the seeded dinners' ids by name.
|
||||
ids map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
inputs []skills.WeekRequest
|
||||
}
|
||||
|
||||
// newEnv returns a plan service over a migrated store in zone
|
||||
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
||||
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner. It
|
||||
// drafts through the real harness and draft_week with the scripted
|
||||
// e.model.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
|
||||
for _, d := range []struct {
|
||||
name, status, method string
|
||||
minutes int64
|
||||
}{
|
||||
{"Beef Chili", "regular", "crockpot", 240},
|
||||
{"Pot Roast", "regular", "crockpot", 0},
|
||||
{"Chicken Fajitas", "regular", "stovetop", 30},
|
||||
{"Spaghetti Bolognese", "regular", "stovetop", 45},
|
||||
{"Sheet Pan Salmon", "regular", "oven", 35},
|
||||
{"Pad Thai", "quest_log", "stovetop", 35},
|
||||
{"Shawarma Bowls", "quest_log", "oven", 40},
|
||||
} {
|
||||
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
e.mu.Lock()
|
||||
e.inputs = append(e.inputs, in)
|
||||
e.mu.Unlock()
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// input returns the request of the ith draft_week run.
|
||||
func (e *env) input(i int) skills.WeekRequest {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.inputs[i]
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *env) start(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// dinner stores a dinner and returns its id; minutes 0 is unknown.
|
||||
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
origin := "regular"
|
||||
if status != "regular" {
|
||||
origin = "side_quest"
|
||||
}
|
||||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: status,
|
||||
Origin: origin,
|
||||
Method: sql.NullString{String: method, Valid: method != ""},
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// reply scripts one draft_week reply planning the dinners named names on
|
||||
// weekDates, in order.
|
||||
func (e *env) reply(note string, names ...string) {
|
||||
e.model.QueueGenerate(e.weekReply(note, names...))
|
||||
}
|
||||
|
||||
// weekReply is the draft_week reply planning the dinners named names on
|
||||
// weekDates, in order.
|
||||
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
|
||||
var days []string
|
||||
for i, name := range names {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name]))
|
||||
}
|
||||
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
|
||||
// on Tuesday.
|
||||
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||
|
||||
// waitJob waits for job id to finish and returns it.
|
||||
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s still %s", id, job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// draft asks for a draft of week with confirm and waits for its job.
|
||||
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
|
||||
t.Helper()
|
||||
d, err := e.svc.StartDraft(t.Context(), week, confirm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.Created {
|
||||
t.Fatalf("draft job %s was already queued", d.JobID)
|
||||
}
|
||||
return e.waitJob(t, d.JobID)
|
||||
}
|
||||
|
||||
// mustDraft drafts week with confirm, requiring the job to succeed, and
|
||||
// returns the plan.
|
||||
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
|
||||
t.Helper()
|
||||
job := e.draft(t, confirm)
|
||||
if job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res struct {
|
||||
WeekStart string `json:"week_start"`
|
||||
PlanID string `json:"plan_id"`
|
||||
}
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.WeekStart != week.String() || res.PlanID != p.ID {
|
||||
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// setStatus sets the plan of week to status, as Push to Calendar would.
|
||||
func (e *env) setStatus(t *testing.T, status string) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
|
||||
status, store.Now().String(), week.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func dayNames(p plan.Plan) []string {
|
||||
var names []string
|
||||
for _, d := range p.Days {
|
||||
names = append(names, d.Dinner.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDraftCreatesPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
|
||||
|
||||
p := e.mustDraft(t, false)
|
||||
|
||||
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
|
||||
}
|
||||
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
|
||||
t.Errorf("ai_note = %v", p.AINote)
|
||||
}
|
||||
if p.CalendarSyncedAt != nil {
|
||||
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
|
||||
}
|
||||
if got := dayNames(p); !slices.Equal(got, fullWeek) {
|
||||
t.Errorf("days = %v, want %v", got, fullWeek)
|
||||
}
|
||||
for i, d := range p.Days {
|
||||
if d.Date != weekDates[i] {
|
||||
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
|
||||
}
|
||||
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
|
||||
if d.IsNew != wantNew {
|
||||
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
|
||||
}
|
||||
if d.Dinner.ID != e.ids[d.Dinner.Name] {
|
||||
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
|
||||
}
|
||||
}
|
||||
chili := p.Days[0]
|
||||
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
|
||||
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
|
||||
}
|
||||
|
||||
// The pool is every Regular and Quest log dinner; last week has no
|
||||
// plan.
|
||||
in := e.input(0)
|
||||
var pool []string
|
||||
for _, c := range in.Pool {
|
||||
pool = append(pool, c.Name+"/"+c.Kind)
|
||||
}
|
||||
slices.Sort(pool)
|
||||
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
|
||||
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
|
||||
if !slices.Equal(pool, want) {
|
||||
t.Errorf("pool = %v, want %v", pool, want)
|
||||
}
|
||||
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
|
||||
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
p := e.mustDraft(t, false)
|
||||
|
||||
newYork, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
|
||||
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||
}
|
||||
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
|
||||
// crock-pot dinners.
|
||||
for _, d := range p.Days[1:] {
|
||||
if d.StartBy != nil {
|
||||
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
|
||||
}
|
||||
}
|
||||
if p.AINote != nil {
|
||||
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
|
||||
}
|
||||
|
||||
// start_by follows the household's dinner time.
|
||||
dinnerTime := "18:45"
|
||||
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||
}
|
||||
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
|
||||
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftGivesLastWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
last := mustDate("2026-10-05")
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
id := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
|
||||
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
|
||||
t.Errorf("last week = %v, want Chicken Fajitas", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// Pad Thai is on a later week's plan, so the Quest log no longer
|
||||
// offers it; an eighth dinner keeps the pool at seven.
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
later := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
|
||||
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
|
||||
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||
e.reply("", names...)
|
||||
p := e.mustDraft(t, false)
|
||||
if got := dayNames(p); !slices.Equal(got, names) {
|
||||
t.Errorf("days = %v", got)
|
||||
}
|
||||
for _, c := range e.input(0).Pool {
|
||||
if c.Name == "Pad Thai" {
|
||||
t.Errorf("pool has Pad Thai, planned on a later week")
|
||||
}
|
||||
}
|
||||
// A redraft of the week keeps offering its own Quest log dinners.
|
||||
e.reply("", names...)
|
||||
e.mustDraft(t, false)
|
||||
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
|
||||
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.draft(t, false)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("model called %d times", n)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenAIFails(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
job := e.draft(t, false)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartDraftIsSingleFlight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.Created || again.Created || again.JobID != first.JobID {
|
||||
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
|
||||
}
|
||||
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !other.Created || other.JobID == first.JobID {
|
||||
t.Errorf("another week's draft = %+v, want its own job", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
first := e.mustDraft(t, false)
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
e.reply("second", reversed...)
|
||||
second := e.mustDraft(t, false)
|
||||
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
|
||||
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
|
||||
}
|
||||
if got := dayNames(second); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
||||
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
first := e.mustDraft(t, false)
|
||||
e.setStatus(t, status)
|
||||
// Push to Calendar records each day's event id.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
|
||||
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Errorf("%d draft jobs, want only the first", queued)
|
||||
}
|
||||
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
e.reply("second", reversed...)
|
||||
p := e.mustDraft(t, true)
|
||||
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
|
||||
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
|
||||
}
|
||||
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
var events int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 7 {
|
||||
t.Errorf("%d days kept their calendar event id, want 7", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
|
||||
// The redraft is asked for while the plan is draft; the plan is synced
|
||||
// while draft_week runs, so the draft must not replace it.
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("second", reversed...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
e.setStatus(t, plan.StatusSynced)
|
||||
close(release)
|
||||
job := e.waitJob(t, d.JobID)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
|
||||
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWeekStart(t *testing.T) {
|
||||
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
|
||||
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
|
||||
}
|
||||
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "next-week", ""} {
|
||||
var invalid *plan.InvalidWeekError
|
||||
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
|
||||
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
|
||||
|
||||
// A Quest log dinner is new, and a crock-pot one gets its start_by.
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
day := p.Days[2]
|
||||
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
|
||||
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
|
||||
}
|
||||
if p.Status != plan.StatusDraft {
|
||||
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
|
||||
}
|
||||
|
||||
// A Regular in place of a Quest log dinner is not new.
|
||||
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
|
||||
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
|
||||
}
|
||||
want := slices.Clone(fullWeek)
|
||||
want[2] = "Fish Tacos"
|
||||
if got := dayNames(p); !slices.Equal(got, want) {
|
||||
t.Errorf("days = %v, want %v", got, want)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(dayNames(got), want) {
|
||||
t.Errorf("Get days = %v, want %v", dayNames(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapStatusTransitions(t *testing.T) {
|
||||
for _, tc := range []struct{ from, want string }{
|
||||
{plan.StatusDraft, plan.StatusDraft},
|
||||
{plan.StatusSynced, plan.StatusChanged},
|
||||
{plan.StatusChanged, plan.StatusChanged},
|
||||
} {
|
||||
t.Run(tc.from, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.setStatus(t, tc.from)
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != tc.want {
|
||||
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapRefusals(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.setStatus(t, plan.StatusSynced)
|
||||
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
|
||||
archived := e.dinner(t, "Old Stew", "archived", "", 0)
|
||||
|
||||
var notPlannable *plan.DinnerNotPlannableError
|
||||
var badDay *plan.InvalidDayError
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
week store.LocalDate
|
||||
date, id string
|
||||
wantError func(error) bool
|
||||
}{
|
||||
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
|
||||
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
|
||||
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
} {
|
||||
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
|
||||
t.Errorf("%s: Swap = %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
|
||||
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
|
||||
// /plans/{weekStart}/days/{date} body.
|
||||
const maxPlanBody = 4 << 10
|
||||
|
||||
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
|
||||
// to draft over a plan pushed to Calendar.
|
||||
type planDraft struct {
|
||||
Confirm *bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
|
||||
type planDaySwap struct {
|
||||
DinnerID *string `json:"dinner_id"`
|
||||
}
|
||||
|
||||
// getPlan answers GET /plans/{weekStart} with the week's plan.
|
||||
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := svc.Get(r.Context(), week)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "read plan", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
// postPlanDraft enqueues the draft of the week and answers 202 with the
|
||||
// job_id that GET /jobs/{id} reports on, without waiting for the job. When
|
||||
// a draft of the week is already queued or running it answers 202 with
|
||||
// that job instead.
|
||||
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "start plan draft", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, d)
|
||||
}
|
||||
}
|
||||
|
||||
// putPlanDay swaps the dinner of one day of the week's plan and answers
|
||||
// with the plan as swapped.
|
||||
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.DinnerID == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
|
||||
return
|
||||
}
|
||||
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "swap plan day", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
|
||||
// when it is not a Monday date.
|
||||
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
||||
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
|
||||
return store.LocalDate{}, false
|
||||
}
|
||||
return week, true
|
||||
}
|
||||
|
||||
// writePlanError maps a plan.Service error to its API error: a week with
|
||||
// no plan or an unknown dinner is 404, a date outside the week is 400, and
|
||||
// drafting over a synced plan without confirm, or planning a dinner that
|
||||
// is neither a Regular nor in the Quest log, is 409.
|
||||
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var day *plan.InvalidDayError
|
||||
var week *plan.InvalidWeekError
|
||||
var notPlannable *plan.DinnerNotPlannableError
|
||||
switch {
|
||||
case errors.As(err, &day):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
|
||||
case errors.As(err, &week):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
|
||||
case errors.As(err, ¬Plannable):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
|
||||
case errors.Is(err, plan.ErrConfirmRequired):
|
||||
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
|
||||
case errors.Is(err, plan.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
|
||||
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const plansPath = "/api/v1/plans/"
|
||||
|
||||
// plansEnv is a router whose plan service has an unstarted job runner, so
|
||||
// drafts stay queued, over a store holding a draft plan of the week of
|
||||
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
|
||||
// on every other day. questLog is the id of a Quest log dinner on no plan.
|
||||
type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
questLog string
|
||||
}
|
||||
|
||||
func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
ctx := t.Context()
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
dinner := func(name, status, method string, minutes int64) string {
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
|
||||
Method: sql.NullString{String: method, Valid: method != ""},
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
|
||||
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
|
||||
id := dinner("Regular "+date, "regular", "stovetop", 30)
|
||||
if i == 0 {
|
||||
id = dinner("Beef Chili", "regular", "crockpot", 240)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
|
||||
}
|
||||
|
||||
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
|
||||
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
|
||||
t.Errorf("plan = %v", body)
|
||||
}
|
||||
days, _ := body["days"].([]any)
|
||||
if len(days) != 7 {
|
||||
t.Fatalf("days = %v, want 7", body["days"])
|
||||
}
|
||||
monday := days[0].(map[string]any)
|
||||
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
|
||||
t.Errorf("Monday = %v", monday)
|
||||
}
|
||||
d := monday["dinner"].(map[string]any)
|
||||
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
|
||||
if _, ok := d[key]; !ok {
|
||||
t.Errorf("dinner has no %s: %v", key, d)
|
||||
}
|
||||
}
|
||||
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
|
||||
t.Errorf("Monday dinner = %v", d)
|
||||
}
|
||||
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
|
||||
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWeekStartMustBeMonday(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
for _, tc := range []struct{ method, target, body string }{
|
||||
{http.MethodGet, plansPath + "2026-10-13", ""},
|
||||
{http.MethodGet, plansPath + "not-a-date", ""},
|
||||
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
|
||||
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
|
||||
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPlanDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
jobID, _ := body["job_id"].(string)
|
||||
if jobID == "" {
|
||||
t.Fatalf("POST draft = %v, want a job_id", body)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
|
||||
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
|
||||
}
|
||||
// A second draft of the week joins the queued one.
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
|
||||
if rec.Code != http.StatusAccepted || body["job_id"] != jobID {
|
||||
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
|
||||
}
|
||||
// A week with no plan needs no confirm.
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
|
||||
if rec.Code != http.StatusAccepted || body["job_id"] == jobID {
|
||||
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []string{"synced", "changed"} {
|
||||
e := newPlansRouter(t)
|
||||
e.setStatus(t, status)
|
||||
for _, b := range []string{"{}", `{"confirm":false}`} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
|
||||
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
|
||||
}
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
|
||||
if rec.Code != http.StatusAccepted || body["job_id"] == nil {
|
||||
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutPlanDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.setStatus(t, "synced")
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["status"] != "changed" {
|
||||
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
|
||||
}
|
||||
wednesday := body["days"].([]any)[2].(map[string]any)
|
||||
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
|
||||
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
target, body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
|
||||
{"2026-10-12/days/2026-10-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"},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
|
||||
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/suggest"
|
||||
)
|
||||
@@ -41,12 +42,14 @@ type RouteMounter interface {
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||
// /decline; runner serves
|
||||
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||
// /api/v1/plans/{weekStart}/draft and PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -62,6 +65,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if suggestSvc == nil {
|
||||
return nil, errors.New("server: nil suggestions service")
|
||||
}
|
||||
if planSvc == nil {
|
||||
return nil, errors.New("server: nil plan service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -116,6 +122,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
||||
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
||||
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
||||
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/secretbox"
|
||||
@@ -49,7 +50,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -185,6 +186,26 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPlan returns a plan service over a migrated temp database, with
|
||||
// its own unstarted job runner and a drafter that always fails.
|
||||
func newTestPlan(t *testing.T) *plan.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
@@ -216,7 +237,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -225,7 +246,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -234,7 +255,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -243,7 +264,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -252,7 +273,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -261,16 +282,25 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- name: GetWeekPlan :one
|
||||
-- The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||
-- there is none.
|
||||
SELECT * FROM week_plans
|
||||
WHERE week_start = ?;
|
||||
|
||||
-- name: ListPlanDays :many
|
||||
-- The days of plan plan_id with their dinners, Monday first. A day with
|
||||
-- no dinner is left out.
|
||||
SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners)
|
||||
FROM plan_days
|
||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||
WHERE plan_days.plan_id = ?
|
||||
ORDER BY plan_days.date;
|
||||
|
||||
-- name: CreateWeekPlan :exec
|
||||
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||
VALUES (?, ?, 'draft', ?, ?, ?);
|
||||
|
||||
-- name: RedraftWeekPlan :exec
|
||||
-- Records a new draft over plan id: its status and note.
|
||||
UPDATE week_plans
|
||||
SET status = ?, ai_note = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: PutPlanDay :exec
|
||||
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||
-- when the day exists.
|
||||
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (plan_id, date) DO UPDATE
|
||||
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at;
|
||||
|
||||
-- name: SwapPlanDay :execrows
|
||||
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||
-- have is left as it is (0 rows).
|
||||
UPDATE plan_days
|
||||
SET dinner_id = ?, is_new = ?, updated_at = ?
|
||||
WHERE plan_id = ? AND date = ?;
|
||||
|
||||
-- name: MarkWeekPlanEdited :exec
|
||||
-- Records an edit of plan id: a synced plan becomes changed, any other
|
||||
-- keeps its status.
|
||||
UPDATE week_plans
|
||||
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||
updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListDraftPool :many
|
||||
-- The dinners a draft of the week starting week_start may plan: every
|
||||
-- Regular, and every Quest log dinner no other plan for the week starting
|
||||
-- from_week_start or later has on any day.
|
||||
SELECT * FROM dinners
|
||||
WHERE status = 'regular'
|
||||
OR (status = 'quest_log' AND NOT EXISTS (
|
||||
SELECT 1 FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE plan_days.dinner_id = dinners.id
|
||||
AND week_plans.week_start >= sqlc.arg(from_week_start)
|
||||
AND week_plans.week_start <> sqlc.arg(week_start)
|
||||
))
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: ListWeekPlanDinnerIDs :many
|
||||
-- The ids of the dinners planned in the week starting week_start, Monday
|
||||
-- first.
|
||||
SELECT plan_days.dinner_id
|
||||
FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
|
||||
ORDER BY plan_days.date;
|
||||
|
||||
-- name: GetActiveDraftJobID :one
|
||||
-- The id of the job of kind queued or running for the week starting
|
||||
-- week_start. Returns sql.ErrNoRows when there is none.
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.week_start') = sqlc.arg(week_start)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
Reference in New Issue
Block a user