diff --git a/cmd/server/main.go b/cmd/server/main.go index ca0834e..f5e4efb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b566ef0..4f4febd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go new file mode 100644 index 0000000..dedc1b7 --- /dev/null +++ b/internal/db/plans.sql.go @@ -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() +} diff --git a/internal/db/querier.go b/internal/db/querier.go index da18d8e..b6c4191 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -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. diff --git a/internal/plan/draft.go b/internal/plan/draft.go new file mode 100644 index 0000000..21fe3c8 --- /dev/null +++ b/internal/plan/draft.go @@ -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)) +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go new file mode 100644 index 0000000..b3c8d0e --- /dev/null +++ b/internal/plan/plan.go @@ -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 +} diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go new file mode 100644 index 0000000..1d02aa7 --- /dev/null +++ b/internal/plan/plan_test.go @@ -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)) + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index bc4dd39..89832dd 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -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) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index e0c71a7..fb978c8 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -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) } diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 24f6a37..16d854e 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -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) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c2ce862..dad2c2d 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -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) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 7242e74..8c3f22b 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -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) } diff --git a/internal/server/plans.go b/internal/server/plans.go new file mode 100644 index 0000000..b2cd605 --- /dev/null +++ b/internal/server/plans.go @@ -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") + } +} diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go new file mode 100644 index 0000000..f0577ec --- /dev/null +++ b/internal/server/plans_test.go @@ -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) + } +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 5e20f89..9931bb6 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index c81b05e..966f21d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 05a3807..a7499a3 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 6586253..09f1be7 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -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) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index e50448b..a0c6549 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -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) } diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql new file mode 100644 index 0000000..3d049da --- /dev/null +++ b/migrations/queries/plans.sql @@ -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;