Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d532254bc6 Take in as much of a Regular's outer punctuation as a reason spells
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m28s
A mention extended over a stored name's optional outer punctuation only
when the reason spelled all of it, so "Like your Beef Chili!" left the
mention of "Beef Chili!!" at "Beef Chili" while "Chili!" extended through
its "!", and neither covered the other. Extend through the longest portion
of the punctuation the reason spells after which the border still holds,
at either end of the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:00:30 -04:00
dev_agentandClaude Opus 5.5 70a4e540aa Ignore spacing beside a Regular's outer punctuation in a mention
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m26s
A mention took in a stored name's leading or trailing punctuation only
where the text repeated the stored spacing exactly, although the rule
says spacing is ignored. With Regulars "Beef Chili !" and "Chili!", the
reason "Like your Beef Chili!" left the first mention at "Beef Chili",
so a citation of "Chili!" passed. Match the punctuation with any spacing
beside it, then apply the same border check to the extended mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:50:36 -04:00
dev_agentandClaude Opus 5.5 e0e934d8a8 Recheck the border after stored punctuation in a Regular's mention
CI / ci (pull_request) Successful in 2m25s
CI / ci (push) Successful in 2m41s
A mention took in the name's trailing or leading punctuation without
checking the border beyond it, so with Regulars "Beef Chili" and "Beef
Chili!" the text "Beef Chili!X" named "Beef Chili!" alone, though the
shared bare mention is ambiguous (MQ-100-6). Punctuation is now taken in
only where the border still holds after it; otherwise the mention stays
the bare name, and stays shared.

Only punctuation and the spacing beside it may be left off a name's ends,
and a combining mark counts as part of its letter, so a stored "Chicken
Cafe" with a decomposed accent is no longer mentioned by "Chicken Cafe",
nor plain "Chicken Cafe" by the accented spelling (MQ-100-7). Symbols are
no longer trimmed either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:41:46 -04:00
dev_agentandClaude Opus 5.5 97eb97582d Define one boundary rule for Regulars a Side quest's reason names
CI / ci (push) Successful in 2m38s
CI / ci (pull_request) Successful in 2m25s
The reason check split on white space and trimmed punctuation before
looking a phrase up, so a stored name ending in punctuation could be
trimmed into a different Regular (MQ-100-4) and a name joined to the
next word by punctuation was never seen (MQ-100-5).

A mention of a Regular is now where the reason spells its name, ignoring
case and spacing, with the name's own outer punctuation optional and no
letter or digit just outside it. A mention names its Regular unless a
different Regular's mention covers the same text or more, which keeps a
mention of "Easy Beef Chili" from naming "Beef Chili" (MQ-100-3).
One table-driven test covers the whole matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:21:31 -04:00
dev_agentandClaude Opus 5.5 622f5359e8 Check a Side quest's reason names the Regular its citation resolved to
CI / ci (pull_request) Successful in 2m39s
CI / ci (push) Successful in 2m30s
The reason check compared normalized names, so a reason naming only
"Beef Chili" passed for a citation of "Easy Beef Chili". Find the
Regulars the reason names with the same find the citations use, taking
the longest run of words at each point, and require a cited Regular
among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:03:40 -04:00
dev_agentandClaude Opus 5.5 1ca246b715 Match cited Regulars unambiguously and reject filler-only names
CI / ci (push) Successful in 2m25s
CI / ci (pull_request) Successful in 2m26s
A cited Regular now resolves by exact name (ignoring case and spacing)
first, then by normalized name only when that names a single Regular;
an ambiguous citation drops the Side quest instead of rewriting it to
whichever Regular shared the key (MQ-100-1).

A Side quest name with no words left after normalizing is malformed and
goes through the repair turn, so it can no longer evade the dedupe or
the dinner-name rule (MQ-100-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:48:12 -04:00
dev_agentandClaude Opus 5.5 fad1aee319 Add suggest_side_quests skill v1
CI / ci (push) Successful in 2m38s
CI / ci (pull_request) Successful in 2m43s
The skill asks for twice the Side quests needed, using the household
context plus the pending Side quests, through an embedded, checksummed
prompt template. A malformed reply gets the harness's one repair turn.
A well-formed Side quest is dropped (and listed in Dropped) when it
repeats a Regular, Quest log dinner, pending or passed name or an
earlier Side quest in the batch, cites no Regular or one that is not a
Regular, or has a reason over 180 characters or naming none of its
cited Regulars. Golden tests use llm.Fake.

Maestro task 85a898e1-f1f5-4414-92f3-3277d86afe90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:34:58 -04:00
42 changed files with 116 additions and 4579 deletions
+2 -20
View File
@@ -27,7 +27,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -107,10 +106,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
@@ -128,7 +123,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, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -252,8 +247,7 @@ 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).
// starts (dinners.NewService registers dinners.KindGroundDinner).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -274,18 +268,6 @@ func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer
}
}
// sideQuests runs suggest_side_quests with the AI settings current at each
// run, sharing guard.
func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester {
return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.SideQuests{}, err
}
return skills.SuggestSideQuests(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) {
+2 -58
View File
@@ -98,7 +98,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
@@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes──
│ └──────── explicit decision ─────┤
│ ├──▶ regular
│ └──▶ archived
└──Pass──▶ archived (suggested name kept for dedupe)
└──Pass──▶ archived (name kept for dedupe)
```
From `tried`, an explicit decision moves the dinner to `regular`, to
@@ -260,62 +260,6 @@ automatic** (D-021).
dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
- Side quest suggestions (`suggest.Service`). `POST
/api/v1/suggestions/batches` creates a `manual` batch and enqueues its
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
status}` without waiting; while a batch is `pending` or `running` it
answers 202 with that batch instead, so at most one runs at a time (the
check, the batch and its job share one write transaction). The job reads the
household context, the deck's names and every declined name, runs
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
grounds the survivors in the model's order with `Grounder.GroundMany`,
as many as are still wanted at a time, until 5 are kept or none are
left. A Side quest no recipe grounds is dropped; each round's grounded
ones are stored before the next — a dinner with status `suggested`,
origin `side_quest`, grounding_status `grounded` with the recipe's
recipe_id, photo_id, method, minutes_total and difficulty and the
model's cuisine, and a `pending` suggestion with the name it is
suggested under, the reason and `cited_regular_ids` — unless, since the
job read its input, a dinner it repeats was added, a suggestion it
repeats passed, or a Regular it cites renamed or archived (checked in
the storing transaction). The job publishes
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If
recording the batch's end fails, its job fails; the job runner retries
a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is
not retried, and the next start fails the job as interrupted. Recording
the batch's end is bounded the same way (one attempt, 5 seconds, made
even during shutdown), so a busy writer cannot hold a stopping worker;
a batch whose end timed out is left `running` with its job. Each time a
batch is started, before the active batch is selected, every batch
still `pending` or `running` with no job queued or running (the process
stopped, or recording its end failed) is set `failed`, so it does not
block the next.
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
cited_regular_ids, status, dinner_status, decided_at, created_at}]}`:
the `pending` suggestions whose dinner is still `suggested`, oldest
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
suggestion `accepted` and moves its dinner to `quest_log`; `POST
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
the dinner; every later batch, and the household context's passed list,
reads the suggestion's own name as declined, so renaming the archived
dinner changes nothing. Both record
the session's subject as the dinner's `updated_by` and answer 200
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
and answers 200; the opposite decision is 409 `suggestion_decided`, a
suggestion whose dinner left the deck some other way 409
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
fewer than 5 in the deck starts a `replenish` batch, whose job id it
returns, unless a batch is already pending or running
(`replenish_job_id` is then null). The batch and its job are written in
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.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
+3 -3
View File
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
batch := s.id("batch")
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt)
}
// plan inserts the week plan starting weekStart with one dinner per date;
+5 -5
View File
@@ -54,16 +54,16 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
SELECT name
SELECT dinners.name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'declined'
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
// The names of the most recently declined suggestions, newest first.
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
if err != nil {
-1
View File
@@ -289,7 +289,6 @@ type Suggestion struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
Status string
+1 -38
View File
@@ -21,8 +21,6 @@ type Querier interface {
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// How many suggestions ListDeck returns.
CountDeck(ctx context.Context) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
@@ -34,13 +32,6 @@ type Querier interface {
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
// 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)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
@@ -51,10 +42,6 @@ type Querier interface {
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
@@ -62,14 +49,8 @@ type Querier interface {
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
@@ -86,10 +67,6 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
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)
// 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
@@ -105,36 +82,22 @@ type Querier interface {
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
ListDeck(ctx context.Context) ([]ListDeckRow, error)
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The 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)
// Reads for the AI household context block (internal/ai/harness).
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
// The names of the most recently declined suggestions, newest first.
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
// 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)
// 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)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
-409
View File
@@ -1,409 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: suggestions.sql
package db
import (
"context"
"database/sql"
)
const countDeck = `-- name: CountDeck :one
SELECT COUNT(*)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
`
// How many suggestions ListDeck returns.
func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countDeck)
var count int64
err := row.Scan(&count)
return count, err
}
const createSuggestion = `-- name: CreateSuggestion :exec
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, 'pending', ?, ?
)
`
type CreateSuggestionParams struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
CreatedAt string
UpdatedAt string
}
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
_, err := q.db.ExecContext(ctx, createSuggestion,
arg.ID,
arg.DinnerID,
arg.BatchID,
arg.Name,
arg.Reason,
arg.CitedRegularIds,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createSuggestionBatch = `-- name: CreateSuggestionBatch :one
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
RETURNING id, "trigger", status, ai_run_id, created_at, updated_at
`
type CreateSuggestionBatchParams struct {
ID string
Trigger string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, createSuggestionBatch,
arg.ID,
arg.Trigger,
arg.CreatedAt,
arg.UpdatedAt,
)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const decideSuggestion = `-- name: DecideSuggestion :execrows
UPDATE suggestions
SET status = ?1, decided_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'pending'
`
type DecideSuggestionParams struct {
Status string
Now sql.NullString
ID string
}
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows
UPDATE suggestion_batches
SET status = 'failed', updated_at = ?1
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?2
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
)
`
type FailOrphanedSuggestionBatchesParams struct {
UpdatedAt string
Kind string
}
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running')
`
type FinishSuggestionBatchParams struct {
Status string
UpdatedAt string
ID string
}
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1
`
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestion = `-- name: GetSuggestion :one
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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 suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.id = ?
`
type GetSuggestionRow struct {
Suggestion Suggestion
Dinner Dinner
}
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) {
row := q.db.QueryRowContext(ctx, getSuggestion, id)
var i GetSuggestionRow
err := row.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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,
)
return i, err
}
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
SELECT id FROM jobs
WHERE kind = ?1
AND json_extract(payload, '$.batch_id') = ?2
ORDER BY id
LIMIT 1
`
type GetSuggestionBatchJobIDParams struct {
Kind string
BatchID string
}
// The id of the job that runs batch batch_id.
func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID)
var id string
err := row.Scan(&id)
return id, err
}
const listDeck = `-- name: ListDeck :many
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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 suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
ORDER BY suggestions.created_at, suggestions.id
`
type ListDeckRow struct {
Suggestion Suggestion
Dinner Dinner
}
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
rows, err := q.db.QueryContext(ctx, listDeck)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDeckRow
for rows.Next() {
var i ListDeckRow
if err := rows.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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 listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id
`
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'suggested'
`
type MoveSuggestedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// 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).
func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveSuggestedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = 'running', updated_at = ?
WHERE id = ? AND status = 'pending'
`
type StartSuggestionBatchParams struct {
UpdatedAt string
ID string
}
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+34 -105
View File
@@ -54,30 +54,14 @@ const (
CodeUnknownKind = "unknown_kind"
)
// Retry backoff. A worker whose claim or final job write fails with a
// database error retries after retryMin, doubling up to retryMax, so a job
// queued when the error clears is claimed without waiting for a wake token,
// and a job the worker has finished is never left running.
// Claim retry backoff. A worker whose claim fails with a database error
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
// when the error clears is claimed without waiting for a wake token.
const (
retryMin = 50 * time.Millisecond
retryMax = 5 * time.Second
claimRetryMin = 50 * time.Millisecond
claimRetryMax = 5 * time.Second
)
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
// handler's own last write, so a writer connection that stays busy cannot
// hold a stopping worker, and with it shutdown, open.
const FinalWriteTimeout = 5 * time.Second
// FinalWriteContext returns the context for one attempt at a final write
// of a job: it is not cancelled with ctx, so a job that finishes during
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
// handler makes its own last write, the one recording what the job did,
// with it too, and returns the error when the attempt times out, so its
// worker can stop; the next Start fails the job as interrupted.
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
}
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
@@ -275,21 +259,6 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
return k.r.enqueue(ctx, k.name, data)
}
// EnqueueTx stores a queued job with payload in tx and returns its id, so
// the job exists exactly when what else tx writes does. Once tx has
// committed, call Wake so an idle worker runs it.
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
}
return createJob(ctx, db.New(tx), k.name, data)
}
// Wake tells an idle worker to look for a job, as Enqueue does itself:
// call it after committing the transaction a job was enqueued in.
func (k *Kind[P]) Wake() { k.r.wakeOne() }
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler and
@@ -311,20 +280,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
// enqueue stores a queued job of kind with its encoded payload and wakes a
// worker.
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
if err != nil {
return "", err
}
r.wakeOne()
return id, nil
}
// createJob stores a queued job of kind with its encoded payload through q
// and returns its id.
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
id := store.NewID()
now := store.Now().String()
if err := q.CreateJob(ctx, db.CreateJobParams{
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
ID: id,
Kind: kind,
Payload: string(payload),
@@ -333,15 +291,11 @@ func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte)
}); err != nil {
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
}
return id, nil
}
// wakeOne gives an idle worker a token to look for a queued job.
func (r *Runner) wakeOne() {
select {
case r.wake <- struct{}{}:
default: // every worker already has a token and will look again
}
return id, nil
}
// Start fails every job a previous process left running with
@@ -403,16 +357,16 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
// already queued is not stranded by a transient error.
func (r *Runner) work(ctx context.Context) {
defer r.wg.Done()
backoff := retryMin
backoff := claimRetryMin
for ctx.Err() == nil {
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
switch {
case err == nil:
backoff = retryMin
backoff = claimRetryMin
r.run(ctx, job)
continue
case errors.Is(err, sql.ErrNoRows):
backoff = retryMin
backoff = claimRetryMin
select {
case <-r.wake:
case <-ctx.Done():
@@ -428,7 +382,7 @@ func (r *Runner) work(ctx context.Context) {
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, retryMax)
backoff = min(2*backoff, claimRetryMax)
}
}
@@ -436,13 +390,14 @@ func (r *Runner) work(ctx context.Context) {
// ctx has been cancelled, so a handler that finishes during shutdown is
// recorded.
func (r *Runner) run(ctx context.Context, job db.Job) {
write := context.WithoutCancel(ctx)
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
q := db.New(r.st.Writer)
h, ok := r.handlers[job.Kind] // read-only once started
if !ok {
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
r.fail(ctx, logger, job.ID, CodeUnknownKind)
r.fail(write, logger, job.ID, CodeUnknownKind)
return
}
@@ -464,7 +419,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
result, code, reason := h.run(ctx, job.Payload, publish)
if code != "" {
if ctx.Err() != nil {
logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start")
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
return
}
level := slog.LevelInfo
@@ -472,61 +427,35 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
level = slog.LevelError
}
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
r.fail(ctx, logger, job.ID, code)
r.fail(write, logger, job.ID, code)
return
}
r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) {
return q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
n, err := q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(write, "record job success", "error", err)
}
}
// fail records the job as failed with code. The record errors it logs come
// from the database, never from a handler.
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) {
return db.New(r.st.Writer).FailJob(write, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
}
// record makes a job's final write, which must change the one running job.
// A database error is retried with backoff until the write lands, so a
// worker never moves on from a job it has finished while leaving it
// running. Each attempt is made with FinalWriteContext, so even when ctx
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
// cancelled, a failed or timed-out attempt is not retried and the job
// stays running, so the next Start fails it as interrupted.
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
backoff := retryMin
for {
attempt, cancel := FinalWriteContext(ctx)
n, err := write(attempt)
cancel()
if err == nil {
if n != 1 {
logger.ErrorContext(ctx, what, "error", "job is not running")
}
return
}
if ctx.Err() != nil {
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err)
return
}
logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, retryMax)
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(ctx, "record job failure", "error", err)
}
}
-132
View File
@@ -187,46 +187,6 @@ func TestJobSucceeds(t *testing.T) {
}
}
func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
start(t, r)
ctx := context.Background()
var rolledBack string
refused := errors.New("refused")
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1})
if err != nil {
return err
}
return refused
}); !errors.Is(err, refused) {
t.Fatalf("WithTx = %v, want the refusal", err)
}
if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) {
t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err)
}
var id string
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2})
return err
}); err != nil {
t.Fatal(err)
}
count.Wake()
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result)
}
}
func TestJobFailsWithFailureCode(t *testing.T) {
t.Parallel()
st := migratedStore(t)
@@ -609,98 +569,6 @@ func TestClaimErrorIsRetried(t *testing.T) {
}
}
func TestFinalWriteErrorIsRetried(t *testing.T) {
st := migratedStore(t)
logs := &logBuffer{}
r := newRunnerLogging(t, st, 1, logs)
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
if _, err := st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'end refused'); END`); err != nil {
t.Fatal(err)
}
start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(logs.String(), "record job success") {
if time.Now().After(deadline) {
t.Fatal("job success never recorded")
}
time.Sleep(5 * time.Millisecond)
}
if _, err := st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_end`); err != nil {
t.Fatal(err)
}
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
if out := logs.String(); !strings.Contains(out, "record job success failed; retrying") {
t.Fatalf("final write error not logged as retried: %s", out)
}
}
func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
st := migratedStore(t)
r := newRunner(t, st, 1)
started, finish := make(chan struct{}), make(chan struct{})
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
close(started)
<-finish
return countResult{Done: p.Steps}, nil
})
stop := start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
<-started
// Hold the only writer connection, so the job's final write waits for it.
conn, err := st.Writer.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
waits := st.Writer.Stats().WaitCount
close(finish)
deadline := time.Now().Add(5 * time.Second)
for st.Writer.Stats().WaitCount == waits {
if time.Now().After(deadline) {
t.Fatal("final write never waited for the writer")
}
time.Sleep(5 * time.Millisecond)
}
stopped := make(chan struct{})
go func() {
stop()
close(stopped)
}()
// The final write's attempt ends after 5 seconds; well past that, the
// worker is waiting for the writer with no bound.
select {
case <-stopped:
case <-time.After(15 * time.Second):
conn.Close()
<-stopped
t.Fatal("worker waited for a held writer through shutdown")
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
if job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
start(t, second)
if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted {
t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted)
}
}
func TestRunnerEnqueueByKindName(t *testing.T) {
t.Parallel()
st := migratedStore(t)
+1 -1
View File
@@ -28,7 +28,7 @@ const (
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),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -86,7 +86,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, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -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), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -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), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+2 -12
View File
@@ -17,7 +17,6 @@ import (
"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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// reservedPrefixes are owned by the API router and are never answered with the
@@ -39,14 +38,12 @@ type RouteMounter interface {
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /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
// 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, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -59,9 +56,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -112,10 +106,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
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.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+7 -41
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
@@ -19,7 +18,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"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/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
@@ -27,7 +25,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -49,7 +46,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), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -163,28 +160,6 @@ func newTestDinners(t *testing.T) *dinners.Service {
return svc
}
// newTestSuggest returns a suggestions service over a migrated temp
// database, with its own unstarted job runner, a grounder that finds
// nothing and a suggester that always fails.
func newTestSuggest(t *testing.T) *suggest.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)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, 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 +191,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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -225,7 +200,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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -234,7 +209,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), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -243,7 +218,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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -252,25 +227,16 @@ 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, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
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 {
t.Fatal("NewRouter accepted a nil suggestions 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), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
-82
View File
@@ -1,82 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
type deck struct {
Suggestions []suggest.Suggestion `json:"suggestions"`
}
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
// its batch_id and the job_id that GET /jobs/{id} reports on, without
// waiting for the job. When a batch is already pending or running it
// answers 202 with that batch instead of starting another.
func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
b, err := svc.StartBatch(r.Context(), suggest.TriggerManual)
if err != nil {
writeSuggestionError(w, r, logger, "start suggestion batch", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, b)
}
}
// getSuggestionDeck answers GET /suggestions/deck with the pending
// suggestions, oldest first.
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.Deck(r.Context())
if err != nil {
writeSuggestionError(w, r, logger, "list suggestion deck", err)
return
}
writeJSON(w, r, logger, http.StatusOK, deck{Suggestions: list})
}
}
// postSuggestionDecision records Try it (accept) or Pass (decline) on a
// suggestion, recording the session's actor as its dinner's updated_by.
func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc {
decide, action := svc.Decline, "decline suggestion"
if accept {
decide, action = svc.Accept, "accept suggestion"
}
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeSuggestionError(w, r, logger, action, err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// writeSuggestionError maps a suggest.Service error to its API error: an
// unknown suggestion is 404, and a decision on one already decided the
// other way, or whose dinner left the deck, is 409.
func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var decided *suggest.DecidedError
switch {
case errors.As(err, &decided):
writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error())
case errors.Is(err, suggest.ErrDinnerNotSuggested):
writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error())
case errors.Is(err, suggest.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
-189
View File
@@ -1,189 +0,0 @@
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
const suggestionsPath = "/api/v1/suggestions"
// newSuggestionsRouter returns a router whose suggestions service has an
// unstarted job runner, so batches stay pending, and a deck seeded with
// pending suggestions of dinners named names, in order, whose ids it
// returns.
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
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)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, 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), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
ctx := t.Context()
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
t.Fatal(err)
}
var ids []string
for i, name := range names {
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
id := store.NewID()
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
CreatedAt: store.Now().String(), UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
return h, st, ids
}
func TestSuggestionDeck(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
}
list, _ := body["suggestions"].([]any)
if len(list) != 2 {
t.Fatalf("deck = %v, want 2 suggestions", body)
}
for i, name := range []string{"Pho", "Ramen"} {
s := list[i].(map[string]any)
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
t.Errorf("deck[%d] = %v, want %s", i, s, name)
}
if _, ok := s["photo_url"]; !ok {
t.Errorf("deck[%d] has no photo_url key", i)
}
}
}
func TestSuggestionDecisions(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
for _, tc := range []struct {
target string
status int
code string
sugStatus string
dinnerStatus string
}{
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
} {
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
if rec.Code != tc.status {
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
}
if tc.code != "" {
if errorCode(body) != tc.code {
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
}
continue
}
s, _ := body["suggestion"].(map[string]any)
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
}
if body["replenish_job_id"] != nil {
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
}
}
// A third decision leaves 4: a replenish batch starts.
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
if rec.Code != http.StatusOK {
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
}
if id, _ := body["replenish_job_id"].(string); id == "" {
t.Errorf("decline leaving 4 = %v, want a replenish_job_id", body)
}
}
func TestPostSuggestionBatch(t *testing.T) {
t.Parallel()
h, st, _ := newSuggestionsRouter(t)
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
}
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
}
var kind string
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
}
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
}
}
func TestSuggestionWritesNeedCSRF(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho")
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
req.AddCookie(validSessionCookie)
rec, body := serve(t, h, req)
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
}
}
}
+4 -80
View File
@@ -3,7 +3,6 @@ package store
import (
"context"
"io/fs"
"reflect"
"slices"
"strings"
"testing"
@@ -176,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
}
}
@@ -197,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -558,78 +557,3 @@ func TestMigrateAIRunsStatus(t *testing.T) {
})
}
}
// TestMigrateSuggestionName upgrades a database at 0011 holding a pending
// and a declined suggestion and checks 0012 keeps both, gives each its
// dinner's name, and refuses a suggestion with no name.
func TestMigrateSuggestionName(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
"0011_ground_dinner_job_revision.sql",
} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES ('batch-1', 'manual', 'done', 'c', 'u')`,
`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at)
VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'),
('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at
FROM suggestions ORDER BY id`)
if err != nil {
t.Fatal(err)
}
var got [][]string
for rows.Next() {
r := make([]string, 10)
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
t.Fatal(err)
}
got = append(got, r)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
want := [][]string{
{"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"},
{"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want)
}
if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err)
}
if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err)
}
if tableExists(t, s, "suggestions_copy") {
t.Error("suggestions_copy left behind")
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
parents: []string{"dinners", "suggestion_batches"},
row: repoRow{"suggestions", map[string]any{
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
"name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
"status": "pending", "decided_at": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
-1
View File
@@ -110,7 +110,6 @@ func testRow(table string, i int) map[string]any {
case "suggestions":
r["dinner_id"] = rowID("dinners", 0)
r["batch_id"] = rowID("suggestion_batches", 0)
r["name"] = fmt.Sprintf("Side quest %d", i)
r["reason"] = "Like your chili."
r["cited_regular_ids"] = "[]"
r["status"] = "pending"
-309
View File
@@ -1,309 +0,0 @@
package suggest
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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct {
BatchID string `json:"batch_id"`
}
// Batch stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// batchResult is the KindSuggestionBatch result, published as the batch
// goes: its stage, the suggestions stored so far, and how many Side
// quests were dropped, by suggest_side_quests' rules, because no recipe
// grounds them, or because they repeat a dinner added meanwhile.
type batchResult struct {
BatchID string `json:"batch_id"`
Stage string `json:"stage"`
SuggestionIDs []string `json:"suggestion_ids"`
Dropped int `json:"dropped"`
}
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
// Side quests given the household context, the deck and every declined
// name, then grounds them in the model's order, at most the number still
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck. When
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
ID: p.BatchID,
})
if err != nil {
return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err)
}
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
}
return res, nil
}
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
var failure *jobs.Failure
if errors.As(err, &failure) {
// Only the code is logged: the error may quote a model's or
// search provider's response.
s.logger.InfoContext(ctx, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code)
}
return batchResult{}, errors.Join(err, finishErr)
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see.
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
}
in, err := s.request(ctx)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
return batchResult{}, ctx.Err()
}
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return batchResult{}, err
}
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, grounded)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone
return res, nil
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every declined name. A household with no Regulars
// fails with ReasonNoRegulars.
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err)
}
if len(hctx.Regulars) == 0 {
return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars}
}
q := db.New(s.st.Reader)
deck, err := q.ListDeck(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err)
}
pending := []string{}
for _, row := range deck {
pending = append(pending, row.Dinner.Name)
}
declined, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
}
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
}
// groundedQuest is a Side quest with the recipe that grounds it.
type groundedQuest struct {
quest skills.SideQuest
grounded recipes.Grounded
}
// ground grounds quests by their search queries and returns those a
// recipe grounds, in order. A Side quest no recipe grounds is left out;
// any other failure fails the batch.
func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) {
reqs := make([]recipes.GroundRequest, len(quests))
for i, q := range quests {
reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery}
}
results := s.grounder.GroundMany(ctx, reqs)
if err := ctx.Err(); err != nil {
return nil, err
}
var out []groundedQuest
for i, r := range results {
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded})
case errors.As(r.Err, &ungroundable):
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
return nil, &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
return nil, &jobs.Failure{Code: ReasonAIFailed}
default:
return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err)
}
}
return out, nil
}
// store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed, or a Regular it cites renamed or archived. Declined
// names are read in the same transaction as the store, so a Pass committed
// before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
ids := []string{}
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
}
now := store.Now().String()
gr := gq.grounded
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: gq.quest.Name,
NormalizedName: harness.Normalize(gq.quest.Name),
Status: dinners.StatusSuggested,
Origin: "side_quest",
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true},
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
GroundingStatus: dinners.GroundingGrounded,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("suggest: create dinner: %w", err)
}
id := store.NewID()
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: id,
DinnerID: d.ID,
BatchID: batchID,
Name: d.Name,
Reason: gq.quest.Reason,
CitedRegularIds: string(citedJSON),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err)
}
names = append(names, d.Name)
ids = append(ids, id)
}
return nil
})
if err != nil {
return nil, err
}
return ids, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
ids := make([]string, len(cited))
for i, name := range cited {
if len(regulars[name]) != 1 {
return nil, false
}
ids[i] = regulars[name][0]
}
return ids, true
}
// repeats reports whether name is harness.Similar to any of names.
func repeats(name string, names []string) bool {
for _, n := range names {
if harness.Similar(name, n) {
return true
}
}
return false
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
-419
View File
@@ -1,419 +0,0 @@
// Package suggest keeps the household's Side quest deck: batches of new
// dinners the AI suggests, each grounded in a recipe, and the household's
// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests
// proposes Side quests, each is grounded with the recipe grounding pipeline
// (dropping those no recipe grounds), and every one kept is stored as a
// dinner with status suggested and origin side_quest and a pending
// suggestion. Only one batch is pending or running at a time.
package suggest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindSuggestionBatch is the job that runs a suggestion batch.
const KindSuggestionBatch = "suggestion_batch"
const (
// BatchSize is how many Side quests a batch keeps at most.
BatchSize = 5
// ReplenishBelow is the deck size under which a decision starts a
// replenish batch.
ReplenishBelow = 5
// ContextBudget is the character budget of the household context
// suggest_side_quests is given (see harness.BuildContext).
ContextBudget = 16000
)
// Batch triggers (the suggestion_batches.trigger column).
const (
TriggerScheduled = "scheduled"
TriggerManual = "manual"
TriggerReplenish = "replenish"
)
// Batch statuses (the suggestion_batches.status column).
const (
BatchPending = "pending"
BatchRunning = "running"
BatchDone = "done"
BatchFailed = "failed"
)
// Suggestion statuses (the suggestions.status column).
const (
StatusPending = "pending"
StatusAccepted = "accepted"
StatusDeclined = "declined"
)
// Reasons a batch failed, stored as the KindSuggestionBatch job's failure
// code. Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNoRegulars: the household has no Regulars for Side quests to
// be based on.
ReasonNoRegulars = "no_regulars"
// ReasonAIFailed: suggest_side_quests, or normalize_recipe while
// grounding, failed; no Side quest is made up instead (D-017).
ReasonAIFailed = "ai_failed"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
)
// Reasons are every batch failure reason.
var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish}
// ErrNotFound is returned for an unknown suggestion id.
var ErrNotFound = errors.New("suggestion not found")
// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner
// was archived or moved out of the deck some other way.
var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested")
// DecidedError is a decision on a suggestion already decided the other
// way.
type DecidedError struct {
// Status is the suggestion's decision: accepted or declined.
Status string
}
func (e *DecidedError) Error() string {
return "the suggestion was already " + e.Status
}
// Suggester runs suggest_side_quests on in; scope names the batch. In
// production it is skills.SuggestSideQuests with the household's
// harness.Config.
type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error)
// Service runs suggestion batches and records decisions.
type Service struct {
st *store.Store
grounder *recipes.Grounder
suggest Suggester
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
}
// NewService returns a service over st that suggests with suggest, grounds
// with grounder and registers the KindSuggestionBatch job on runner, which
// must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("suggest: nil store")
case grounder == nil:
return nil, errors.New("suggest: nil grounder")
case suggest == nil:
return nil, errors.New("suggest: nil suggester")
case runner == nil:
return nil, errors.New("suggest: nil job runner")
case logger == nil:
return nil, errors.New("suggest: nil logger")
}
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
if err != nil {
return nil, err
}
s.batchJob = kind
return s, nil
}
// Batch is a suggestion batch with the id of the job running it.
type Batch struct {
ID string `json:"batch_id"`
JobID string `json:"job_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
// Created says whether this call created the batch rather than found
// it already pending or running.
Created bool `json:"-"`
}
// StartBatch creates a batch with trigger and enqueues the
// KindSuggestionBatch job that runs it, unless a batch is already pending
// or running, which it returns instead with Created false.
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
b, err = s.startBatch(ctx, tx, trigger)
return err
})
if err != nil {
return Batch{}, err
}
if b.Created {
s.batchJob.Wake()
}
return b, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. A
// batch still pending or running whose job is no longer queued or running
// is failed first, so a batch whose job ended without recording its end
// (the process stopped, or the write failed) never blocks a later one. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: fail orphaned batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
}
cur, err := q.GetActiveSuggestionBatch(ctx)
if err == nil {
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err == nil && n != 1 {
err = errors.New("batch is not running")
}
if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
}
return nil
}
// Suggestion is a Side quest suggestion as the API shows it, with its
// dinner.
type Suggestion struct {
ID string `json:"id"`
DinnerID string `json:"dinner_id"`
Name string `json:"name"`
PhotoURL *string `json:"photo_url"`
Difficulty *string `json:"difficulty"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Cuisine *string `json:"cuisine"`
// Reason is the "Why this?".
Reason string `json:"reason"`
CitedRegularIDs []string `json:"cited_regular_ids"`
Status string `json:"status"`
DinnerStatus string `json:"dinner_status"`
DecidedAt *string `json:"decided_at"`
CreatedAt string `json:"created_at"`
}
// Deck returns the pending suggestions whose dinner is still suggested,
// oldest first.
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list deck: %w", err)
}
out := make([]Suggestion, len(rows))
for i, row := range rows {
out[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return nil, err
}
}
return out, nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision started one.
type Decision struct {
Suggestion Suggestion `json:"suggestion"`
ReplenishJobID *string `json:"replenish_job_id"`
}
// Accept records Try it on suggestion id, moving its dinner to the Quest
// log and recording updatedBy on it.
func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy)
}
// Decline records Pass on suggestion id, archiving its dinner and
// recording updatedBy on it. The suggestion keeps the name its dish was
// suggested under, whatever the dinner is later renamed, and every later
// batch reads it as declined, so the dish is never suggested again.
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
}
// decide records decision on suggestion id and moves its dinner to
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
// batch unless one is already pending or running, in the decision's own
// transaction: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
}
var row db.GetSuggestionRow
var replenish *Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetSuggestion(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
switch cur.Suggestion.Status {
case decision:
row = cur
return nil
case StatusPending:
default:
return &DecidedError{Status: cur.Suggestion.Status}
}
if cur.Dinner.Status != dinners.StatusSuggested {
return ErrDinnerNotSuggested
}
now := store.Now().String()
if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{
Status: decision,
Now: sql.NullString{String: now, Valid: true},
ID: id,
}); err != nil {
return fmt.Errorf("suggest: decide %s: %w", id, err)
}
if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{
Status: dinnerStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.Dinner.ID,
}); err != nil {
return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err)
}
row, err = q.GetSuggestion(ctx, id)
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
left, err := q.CountDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
if err != nil {
return err
}
replenish = &b
return nil
})
if err != nil {
return Decision{}, err
}
created := replenish != nil && replenish.Created
if created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
if err != nil {
return Decision{}, err
}
d := Decision{Suggestion: sug}
if created {
d.ReplenishJobID = &replenish.JobID
}
return d, nil
}
func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) {
out := Suggestion{
ID: sg.ID,
DinnerID: d.ID,
Name: d.Name,
Difficulty: nullString(d.Difficulty),
Method: nullString(d.Method),
Cuisine: nullString(d.Cuisine),
Reason: sg.Reason,
Status: sg.Status,
DinnerStatus: d.Status,
DecidedAt: nullString(sg.DecidedAt),
CreatedAt: sg.CreatedAt,
}
if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil {
return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds)
}
if d.MinutesTotal.Valid {
out.MinutesTotal = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.PhotoURL = &u
}
return out, nil
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested
-- under. Its dinner can be renamed, even once archived by a Pass, so the
-- declined names every later batch refuses are read from here, never from
-- the dinner. A suggestion stored before this takes its dinner's name now.
-- SQLite cannot add a NOT NULL column without a default, so the table is
-- rebuilt as 0005 does: copy the rows aside, drop the table, create it
-- again, copy the rows back and drop the copy. Nothing references
-- suggestions.
CREATE TABLE suggestions_copy AS SELECT * FROM suggestions;
DROP TABLE suggestions;
CREATE TABLE suggestions (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
name TEXT NOT NULL CHECK (name <> ''),
reason TEXT NOT NULL,
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
decided_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at
)
SELECT
suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name,
suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status,
suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at
FROM suggestions_copy
JOIN dinners ON dinners.id = suggestions_copy.dinner_id;
DROP TABLE suggestions_copy;
+5 -5
View File
@@ -1,12 +1,12 @@
-- Reads for the AI household context block (internal/ai/harness).
-- name: ListRecentDeclinedNames :many
-- The names the most recently declined suggestions were suggested under,
-- newest first; renaming their dinners does not change them.
SELECT name
-- The names of the most recently declined suggestions, newest first.
SELECT dinners.name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'declined'
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
LIMIT ?;
-- name: ListPlannedDinners :many
-103
View File
@@ -1,103 +0,0 @@
-- name: CreateSuggestionBatch :one
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
RETURNING *;
-- name: GetActiveSuggestionBatch :one
-- The batch still pending or running, oldest first. Returns sql.ErrNoRows
-- when there is none.
SELECT * FROM suggestion_batches
WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1;
-- name: GetSuggestionBatchJobID :one
-- The id of the job that runs batch batch_id.
SELECT id FROM jobs
WHERE kind = sqlc.arg(kind)
AND json_extract(payload, '$.batch_id') = sqlc.arg(batch_id)
ORDER BY id
LIMIT 1;
-- name: StartSuggestionBatch :execrows
-- Marks a pending batch running; a batch in any other status is left as
-- it is (0 rows).
UPDATE suggestion_batches
SET status = 'running', updated_at = ?
WHERE id = ? AND status = 'pending';
-- name: FinishSuggestionBatch :execrows
-- Records a pending or running batch as done or failed; a batch already
-- finished is left as it is (0 rows).
UPDATE suggestion_batches
SET status = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running');
-- name: FailOrphanedSuggestionBatches :execrows
-- Fails every batch still pending or running whose job is no longer queued
-- or running: the process stopped, or recording the batch's end failed,
-- before its handler recorded a result. Run before reading the active batch.
UPDATE suggestion_batches
SET status = 'failed', updated_at = sqlc.arg(updated_at)
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
);
-- name: CreateSuggestion :exec
-- name is the name the dish is suggested under, kept whatever its dinner is
-- later renamed.
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, 'pending', ?, ?
);
-- name: GetSuggestion :one
-- A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.id = ?;
-- name: ListDeck :many
-- The pending suggestions whose dinner is still suggested, oldest first,
-- with their dinners.
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
ORDER BY suggestions.created_at, suggestions.id;
-- name: CountDeck :one
-- How many suggestions ListDeck returns.
SELECT COUNT(*)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- name: ListDeclinedSuggestionNames :many
-- The name every declined suggestion was suggested under, oldest decision
-- first; renaming its dinner does not change it.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows
-- Records the household's decision on a pending suggestion; one already
-- decided is left as it is (0 rows).
UPDATE suggestions
SET status = sqlc.arg(status), decided_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: MoveSuggestedDinner :execrows
-- 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).
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'suggested';
-14
View File
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -103,19 +102,6 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
})
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
-2
View File
@@ -3,7 +3,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
@@ -36,7 +35,6 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+8 -77
View File
@@ -14,7 +14,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>('plan.draft', id), { wrapper, initialProps: { id: jobId } })
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
}
// advance runs the timers due in ms and lets the polls they start answer.
@@ -42,7 +42,7 @@ describe('useJob', () => {
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
)
const { result } = renderJob('j1')
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(result.current.status).toBe('queued')
@@ -51,7 +51,7 @@ describe('useJob', () => {
await advance(499)
expect(fetchMock).toHaveBeenCalledTimes(1)
await advance(1)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
await advance(999)
expect(fetchMock).toHaveBeenCalledTimes(2)
@@ -62,7 +62,7 @@ describe('useJob', () => {
expect(fetchMock).toHaveBeenCalledTimes(4)
await advance(2000)
expect(fetchMock).toHaveBeenCalledTimes(5)
expect(result.current).toMatchObject({
expect(result.current).toEqual({
status: 'succeeded',
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
error: null,
@@ -106,76 +106,10 @@ describe('useJob', () => {
expect(fetchMock).toHaveBeenCalledTimes(2)
await advance(1)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
expect(vi.getTimerCount()).toBe(0)
})
it.each([
['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }],
['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }],
['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }],
['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }],
['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }],
['not a job', ['j1']],
])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => {
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body))
const { result } = renderJob('j1')
await advance(0)
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1')
expect(vi.getTimerCount()).toBe(0)
await advance(10_000)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => {
const fetchMock = stubFetch(
jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }),
job('running', { dinners: ['Tacos'] }),
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
)
const { result } = renderJob('j1')
await advance(0)
expect(result.current.error).not.toBeNull()
expect(vi.getTimerCount()).toBe(0)
act(() => result.current.poll())
await advance(0)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.current).toMatchObject({ status: 'succeeded', error: null })
})
it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => {
const fetchMock = stubFetch(
job('running', null),
errorResponse(503, 'unavailable', true),
errorResponse(503, 'unavailable', true),
job('running', { dinners: ['Tacos'] }),
job('succeeded', { dinners: ['Tacos'] }),
)
const { result } = renderJob('j1')
await advance(0)
await advance(500)
await advance(1000)
expect(fetchMock).toHaveBeenCalledTimes(3)
// The next poll was due 2 s later; poll asks now.
act(() => result.current.poll())
await advance(0)
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
expect(vi.getTimerCount()).toBe(1)
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(5)
expect(result.current.status).toBe('succeeded')
})
it('stops polling on unmount after a failed poll', async () => {
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
const { result, unmount } = renderJob('j1')
@@ -191,15 +125,12 @@ describe('useJob', () => {
})
it('stops polling the old job when the job id changes', async () => {
const fetchMock = stubFetch(
job('running', null),
jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }),
)
const fetchMock = stubFetch(job('running', null), job('running', null))
const { result, rerender } = renderJob('j1')
await advance(0)
rerender({ id: 'j2' })
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
expect(vi.getTimerCount()).toBe(1)
@@ -234,6 +165,6 @@ describe('useJob', () => {
const { result } = renderJob(null)
await advance(5000)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
})
})
+28 -73
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useApi } from './ApiContext.tsx'
import { isRecord } from './errors.ts'
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
const STATUSES: readonly string[] = ['queued', 'running', 'succeeded', 'failed'] satisfies JobStatus[]
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
// is running (a partial result); error is set only when it failed.
export type Job<R> = {
@@ -21,56 +18,28 @@ export type JobState<R> = {
status: JobStatus | undefined
// The latest result, partial while the job runs.
result: R | null
// The job's failure, the latest polling request's own failure (cleared
// by the next poll that answers), or an answer that is not this job.
// The job's failure, or the latest polling request's own failure (cleared
// by the next poll that answers).
error: Error | null
// poll asks for the job now, starting the backoff again — also after
// polling stopped on an answer that is not this job.
poll: () => void
}
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
export const FIRST_DELAY_MS = 500
export const MAX_DELAY_MS = 2000
type JobData<R> = Omit<JobState<R>, 'poll'>
const idle = { status: undefined, result: null, error: null }
// parseJob checks a GET /api/v1/jobs/{id} answer is the kind of job asked
// for, with a known status. result is passed on unread for the caller to
// check.
function parseJob<R>(where: string, kind: string, jobId: string, body: unknown): Job<R> {
if (
!isRecord(body) ||
body.id !== jobId ||
body.kind !== kind ||
typeof body.status !== 'string' ||
!STATUSES.includes(body.status) ||
!('result' in body) ||
(body.error !== null && typeof body.error !== 'string')
) {
throw new Error(`${where}: response is not the ${kind} job ${jobId}`)
}
return { id: jobId, kind, status: body.status as JobStatus, result: body.result as R | null, error: body.error as string | null }
}
function isTerminal(status: JobStatus): boolean {
return status === 'succeeded' || status === 'failed'
}
// useJob polls GET /api/v1/jobs/{jobId}, a job of the given kind, until it
// succeeds or fails, backing off from 500 ms to 2 s. A failed poll is
// exposed as the error and polling continues. An answer that is not this
// job is exposed as the error and stops polling until poll is called.
// Polling also stops at a terminal status, when jobId changes and on
// unmount. A null jobId polls nothing.
export function useJob<R>(kind: string, jobId: string | null): JobState<R> {
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
// polling continues. Polling stops only at a terminal status, when jobId
// changes and on unmount. A null jobId polls nothing.
export function useJob<R>(jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobData<R> }>({ jobId, job: idle })
// Each poll call bumps round, which restarts the polling effect.
const [round, setRound] = useState(0)
const poll = useCallback(() => setRound((r) => r + 1), [])
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
useEffect(() => {
if (jobId === null) return
@@ -78,51 +47,37 @@ export function useJob<R>(kind: string, jobId: string | null): JobState<R> {
let timer: ReturnType<typeof setTimeout> | undefined
let delay = FIRST_DELAY_MS
async function fetchJob() {
const path = `/api/v1/jobs/${encodeURIComponent(jobId!)}`
let body: unknown
function next() {
timer = setTimeout(poll, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
}
async function poll() {
try {
body = await apiFetch<unknown>(path)
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
if (!active) return
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
setState({ jobId, job: { status: job.status, result: job.result, error } })
if (isTerminal(job.status)) return
} catch (error) {
if (!active) return
// A failed poll is not the job's end: keep the last known status and
// result, expose the request's error, and poll again.
fail(error instanceof Error ? error : new Error(String(error)))
next()
return
setState((prev) => ({
jobId,
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
}))
}
if (!active) return
let job: Job<R>
try {
job = parseJob<R>(`GET ${path}`, kind, jobId!, body)
} catch (error) {
// An answer that is not this job will not become one by asking
// again: expose it and stop.
fail(error as Error)
return
}
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
setState({ jobId, job: { status: job.status, result: job.result, error } })
if (!isTerminal(job.status)) next()
next()
}
// fail keeps the last known status and result, with error.
function fail(error: Error) {
setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error } }))
}
function next() {
timer = setTimeout(fetchJob, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
}
void fetchJob()
void poll()
return () => {
active = false
clearTimeout(timer)
}
}, [apiFetch, kind, jobId, round])
}, [apiFetch, jobId])
// State left from a previous jobId is not this job's.
return { ...(state.jobId === jobId ? state.job : idle), poll }
return state.jobId === jobId ? state.job : idle
}
+2 -2
View File
@@ -79,7 +79,7 @@ export const REGULARS_KEY = ['dinners', 'regular'] as const
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
export function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
}
@@ -99,7 +99,7 @@ const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
// photoId reads the photo id out of a photo_url, which the server always
// sends as /media/photos/{id}-400.jpg or null.
export function photoId(where: string, value: unknown): string | undefined {
function photoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
-63
View File
@@ -1,63 +0,0 @@
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
import { Photo } from '../ui/Photo.tsx'
import type { SideQuest } from './api.ts'
import styles from './DiscoverPage.module.css'
// DECK_DEPTH is the depths the deck shows, top card first.
export const DECK_DEPTH = [0, 1, 2] as const
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
export type DeckCardProps = {
quest: SideQuest
// 0 is the top card; 1 and 2 stack behind it.
depth: number
}
// DeckCard is one Side quest in the deck. Only the top card is read out and
// reachable; the cards behind it are the stack's edge, kept rendered so they
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
// decision shows them.
export function DeckCard({ quest, depth }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
<h2 id={titleId} className={styles.name}>
{quest.name}
</h2>
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
<p className={styles.why}>
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
Pass
</span>
</article>
)
}
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
const level = DIFFICULTIES.indexOf(difficulty) + 1
return (
<p className={styles.difficulty}>
<span className={styles.dots} aria-hidden="true">
{DIFFICULTIES.map((d, i) => (
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
))}
</span>
{DIFFICULTY_LABEL[difficulty]}
</p>
)
}
-245
View File
@@ -1,245 +0,0 @@
/* Discover · Side quests (mock 1a). */
.page {
box-sizing: border-box;
max-width: 440px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 8px;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
/* The Quest log count pill, in a 44 px tall hit box. */
.questLogButton {
flex: none;
display: inline-flex;
align-items: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.questLog {
flex: none;
padding: 7px 12px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
}
/* The Quest log's dinner names, under the header while the pill is open. */
.questLogList {
flex: 1 0 100%;
font-family: var(--font-body);
font-size: 14px;
}
.questLogNames {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.questLogEmpty {
margin: 0;
color: var(--color-neutral-700);
}
/* The deck: three cards stacked in the same box, the top one in front. */
.deck {
position: relative;
height: 440px;
perspective: 1000px;
}
.card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
border-radius: var(--radius-card);
overflow: hidden;
color: var(--color-text);
}
.depth0 {
z-index: 3;
transform: none;
background: var(--color-neutral-100);
box-shadow: var(--shadow-lg);
}
.depth1 {
z-index: 2;
transform: translateY(8px) scale(0.95);
background: var(--color-surface);
}
.depth2 {
z-index: 1;
transform: translateY(16px) scale(0.9);
background: var(--color-neutral-300);
}
.photo {
flex: none;
width: calc(100% - 20px);
height: 220px;
}
.content {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 20px 0;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.difficulty {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.dots {
display: flex;
gap: 3px;
}
.dot,
.dotFilled {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.dotFilled {
background: var(--color-accent);
}
/* The "Why this?" box. */
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.45;
}
/* The decision stamps, hidden until a decision shows them. */
.stamp {
position: absolute;
top: 26px;
padding: 6px 14px;
border: 3px solid;
border-radius: 14px;
font-family: var(--font-heading);
font-size: 24px;
opacity: 0;
pointer-events: none;
}
.tryStamp {
left: 22px;
transform: rotate(-12deg);
border-color: var(--color-accent-2-700);
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
.passStamp {
right: 22px;
transform: rotate(12deg);
border-color: var(--color-neutral-800);
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
/* Pass and Try it: IconButtons with their 11 px labels shown. */
.actions {
display: flex;
justify-content: center;
gap: 28px;
margin-top: 4px;
}
/* Skeleton cards while the deck loads or a batch finds Side quests. */
.loading {
display: flex;
flex-direction: column;
gap: 22px;
}
.deck > .skeletonCard {
position: absolute;
inset: 0;
height: auto;
border-radius: var(--radius-card);
}
.skeletonCard.depth0,
.skeletonCard.depth1,
.skeletonCard.depth2 {
background: var(--color-surface);
box-shadow: none;
}
.finding {
margin: 0;
text-align: center;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
-488
View File
@@ -1,488 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { DiscoverPage } from './DiscoverPage.tsx'
const DECK = '/api/v1/suggestions/deck'
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it.
function suggestionBody(id: string, name: string, overrides: Record<string, unknown> = {}) {
return {
id,
dinner_id: `dinner-${id}`,
name,
photo_url: null,
difficulty: 'easy',
method: 'stovetop',
minutes_total: 25,
cuisine: 'Korean',
reason: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.',
cited_regular_ids: ['r1', 'r2'],
status: 'pending',
dinner_status: 'suggested',
decided_at: null,
created_at: '2026-10-04T08:00:00.000Z',
...overrides,
}
}
const BOWLS = suggestionBody('s1', 'Korean beef bowls', { photo_url: '/media/photos/p1-400.jpg' })
const TIKKA = suggestionBody('s2', 'Crock pot chicken tikka masala', { difficulty: 'medium', reason: 'Butter chicken night is a favorite.' })
const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'hard' })
type Responder = () => Response | Promise<Response>
// stubServer stands in for the API: GET of the deck answers the current
// server.deck and the Quest log has questLog dinners; every other request
// is answered by responders, keyed "METHOD path". Any other request fails
// the test.
function stubServer(deck: unknown[], responders: Record<string, Responder> = {}, questLog = 6) {
const server = { deck, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: Array.from({ length: questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
...responders,
}
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', server.fetchMock)
return server
}
// sent lists the requests other than GETs, as "METHOD path".
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => `${init?.method} ${String(input)}`)
}
// jobPolls counts the GET /api/v1/jobs/j1 requests.
function jobPolls(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length
}
afterEach(() => {
vi.unstubAllGlobals()
})
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Discover page.
async function renderPage() {
const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<DiscoverPage />
</QueryClientProvider>
</ApiProvider>,
)
}
function deferred() {
let resolve!: (response: Response) => void
const promise = new Promise<Response>((r) => (resolve = r))
return { promise, resolve }
}
describe('DiscoverPage', () => {
it('renders mock 1a from the deck: header, Quest log pill and three stacked cards', async () => {
stubServer([BOWLS, TIKKA, ORZO])
const { container } = await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
expect(screen.getByText('Discover')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Side quests' })).toBeInTheDocument()
const pill = await screen.findByText('Quest log · 6')
expect(declaredStyle(pill).background).toBe('var(--color-accent-2-200)')
expect(declaredStyle(pill).color).toBe('var(--color-accent-2-800)')
// The deck: 440 px, perspective 1000px, three cards stacked by depth.
const cards = container.querySelectorAll('article')
expect(cards).toHaveLength(3)
const deck = cards[0]!.parentElement!
expect(getComputedStyle(deck).height).toBe('440px')
expect(getComputedStyle(deck).perspective).toBe('1000px')
const [top, second, third] = [...cards].map((c) => declaredStyle(c))
expect(top!.transform).toBe('none')
expect(top!.background).toBe('var(--color-neutral-100)')
expect(top!.boxShadow).toBe('var(--shadow-lg)')
expect(second!.transform).toBe('translateY(8px) scale(0.95)')
expect(second!.background).toBe('var(--color-surface)')
expect(third!.transform).toBe('translateY(16px) scale(0.9)')
expect(third!.background).toBe('var(--color-neutral-300)')
for (const style of [top, second, third]) expect(style!.borderRadius).toBe('var(--radius-card)')
// Only the top card is read out; the ones behind it are the stack's edge.
expect(cards[1]).toHaveAttribute('aria-hidden', 'true')
expect(cards[2]).toHaveAttribute('aria-hidden', 'true')
expect(screen.queryByRole('article', { name: 'Crock pot chicken tikka masala' })).not.toBeInTheDocument()
// The top card: washed photo, Bagel name, difficulty dots, "Why this?".
const photo = within(card).getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(getComputedStyle(photo).height).toBe('220px')
expect(photo.style.getPropertyValue('--photo-inset')).toBe('10px')
expect(photo.style.getPropertyValue('--photo-radius')).toBe('24px')
const name = within(card).getByRole('heading', { level: 2, name: 'Korean beef bowls' })
expect(getComputedStyle(name).fontSize).toBe('24px')
const difficulty = within(card).getByText('Easy')
const dots = difficulty.querySelectorAll('[data-filled]')
expect([...dots].map((dot) => dot.getAttribute('data-filled'))).toEqual(['true', 'false', 'false'])
expect(getComputedStyle(dots[0]!).width).toBe('8px')
const why = within(card).getByText('Why this?').parentElement!
expect(why).toHaveTextContent('Why this? You make Chicken stir-fry and Beef tacos most weeks.')
expect(declaredStyle(why).background).toBe('var(--color-accent-2-100)')
// The stamps are there, hidden until a decision shows them.
const tryStamp = within(card).getByText('Try it!')
const passStamp = within(card).getByText('Pass')
expect(getComputedStyle(tryStamp).opacity).toBe('0')
expect(getComputedStyle(tryStamp).transform).toBe('rotate(-12deg)')
expect(getComputedStyle(passStamp).opacity).toBe('0')
expect(getComputedStyle(passStamp).transform).toBe('rotate(12deg)')
// Pass and Try it: 62 px buttons with 11 px labels.
for (const label of ['Pass', 'Try it']) {
const button = screen.getByRole('button', { name: label })
expect(getComputedStyle(button).fontSize).toBe('11px')
expectMinHitArea(button)
expect(getComputedStyle(button.querySelector('span')!).width).toBe('62px')
}
})
it.each([
[TIKKA, 'Medium', 2],
[ORZO, 'Hard', 3],
] as const)('shows $1 with filled dots', async (quest, label, filled) => {
stubServer([quest])
await renderPage()
const card = await screen.findByRole('article', { name: quest.name })
expect(within(card).getByText(label).querySelectorAll('[data-filled="true"]')).toHaveLength(filled)
})
it('records Try it on → and Pass on ←, for the top card', async () => {
const server = stubServer([BOWLS, TIKKA, ORZO], {
'POST /api/v1/suggestions/s1/accept': () => {
server.deck = [TIKKA, ORZO]
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })
},
'POST /api/v1/suggestions/s2/decline': () => {
server.deck = [ORZO]
return jsonResponse(200, { suggestion: { ...TIKKA, status: 'declined' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
fireEvent.keyDown(window, { key: 'ArrowLeft' })
await screen.findByRole('article', { name: 'Lemon garlic shrimp orzo' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline'])
})
it('records Pass and Try it from the buttons', async () => {
const server = stubServer([BOWLS, TIKKA], {
'POST /api/v1/suggestions/s1/decline': () => {
server.deck = [TIKKA]
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: null })
},
'POST /api/v1/suggestions/s2/accept': () => {
server.deck = []
return jsonResponse(200, { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
await screen.findByRole('heading', { name: 'All caught up' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/s2/accept'])
})
it('locks input while a decision is in flight', async () => {
const accept = deferred()
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled()
fireEvent.keyDown(window, { key: 'ArrowLeft' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
server.deck = [TIKKA]
accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }))
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
})
it('leaves ← / → alone with a modifier or while typing in a field', async () => {
const server = stubServer([BOWLS])
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight', shiftKey: true })
fireEvent.keyDown(window, { key: 'ArrowLeft', metaKey: true })
const field = document.createElement('input')
document.body.append(field)
fireEvent.keyDown(field, { key: 'ArrowRight' })
field.remove()
expect(sent(server.fetchMock)).toEqual([])
})
it('shows a refused decision with a retry, keeping the card', async () => {
let refuse = true
const server = stubServer([BOWLS], {
'POST /api/v1/suggestions/s1/accept': () => {
if (refuse) return errorResponse(500, 'internal')
server.deck = []
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
refuse = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('heading', { name: 'All caught up' })
})
it('shows "All caught up" when the deck is empty', async () => {
stubServer([])
await renderPage()
expect(await screen.findByRole('heading', { level: 2, name: 'All caught up' })).toBeInTheDocument()
expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Try it' })).not.toBeInTheDocument()
})
it('Get more now starts a batch and shows skeleton cards until its Side quests arrive', async () => {
const jobs = [
{ id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null },
{ id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, error: null },
]
const server = stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () => {
const job = jobs.shift()!
if (job.status === 'succeeded') server.deck = [BOWLS]
return jsonResponse(200, job)
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument()
await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 })
expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches'])
})
it('shows a failed batch with a retry over the empty state', async () => {
stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () =>
jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'failed', result: null, error: 'model unavailable' }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable")
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
it('shows a deck that fails to load with a retry', async () => {
let fail = true
stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS] })) })
await renderPage()
const alert = await screen.findByRole('alert', {}, { timeout: 5000 })
expect(alert).toHaveTextContent("Couldn't load your side quests.")
fail = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: 'Korean beef bowls' })
})
it('opens and closes the Quest log from its pill, a keyboard-reachable button', async () => {
stubServer([BOWLS], {}, 2)
await renderPage()
const pill = await screen.findByRole('button', { name: 'Quest log · 2' })
expect(pill).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
expectMinHitArea(pill)
pill.focus()
expect(pill).toHaveFocus()
fireEvent.click(pill)
expect(pill).toHaveAttribute('aria-expanded', 'true')
const list = screen.getByRole('list', { name: 'Quest log' })
expect(pill).toHaveAttribute('aria-controls', list.parentElement!.id)
expect(within(list).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Quest 0', 'Quest 1'])
fireEvent.click(pill)
expect(pill).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
})
it('says so when the Quest log it opens is empty', async () => {
stubServer([BOWLS], {}, 0)
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Quest log · 0' }))
expect(screen.getByText('No dinners in the Quest log yet. Try it on a Side quest to add one.')).toBeInTheDocument()
})
it('sends one decision for presses made before the page re-renders', async () => {
const accept = deferred()
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowLeft' })
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
await waitFor(() => expect(sent(server.fetchMock)).toContain('POST /api/v1/suggestions/s1/accept'))
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
server.deck = [TIKKA]
accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }))
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
fireEvent.keyDown(window, { key: 'ArrowLeft' })
await waitFor(() =>
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']),
)
})
it.each([
['not a decision', {}],
['another suggestion', { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }],
['not accepted', { suggestion: { ...BOWLS, status: 'pending' }, replenish_job_id: null }],
])('shows a decision answered with %s as unsaved, keeping the card', async (_, answer) => {
stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => jsonResponse(200, answer) })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice. POST /api/v1/suggestions/s1/accept: response is not the accepted suggestion")
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
})
it('shows a batch whose job cannot be polled with a retry, keeping the skeleton cards', async () => {
let failPoll = true
const server = stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () => {
if (failPoll) return errorResponse(500, 'internal')
server.deck = [BOWLS]
return jsonResponse(200, {
id: 'j1',
kind: 'suggestion_batch',
status: 'succeeded',
result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 },
error: null,
})
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on new side quests.")
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
// Retry polls the job at once, well before the next poll is due; it
// does not ask for another batch.
failPoll = false
const polls = jobPolls(server.fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(polls + 1), { timeout: 200 })
await screen.findByRole('article', { name: 'Korean beef bowls' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches'])
})
it('shows a batch whose job answers with an unknown status as an error, and Retry polls the job again', async () => {
let status = 'stuck'
const server = stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () => {
if (status === 'succeeded') server.deck = [BOWLS]
return jsonResponse(200, {
id: 'j1',
kind: 'suggestion_batch',
status,
result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 },
error: null,
})
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
"Couldn't check on new side quests. GET /api/v1/jobs/j1: response is not the suggestion_batch job j1",
)
// Polling has stopped: nothing more is asked until Retry.
await new Promise((resolve) => setTimeout(resolve, 700))
expect(jobPolls(server.fetchMock)).toBe(1)
status = 'succeeded'
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: 'Korean beef bowls' })
expect(jobPolls(server.fetchMock)).toBe(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches'])
})
it('shows a batch whose job result is unreadable with a retry', async () => {
stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () =>
jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1' }, error: null }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find new side quests. GET /api/v1/jobs/j1: result is not a suggestion batch")
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
})
-210
View File
@@ -1,210 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import {
BATCH_JOB_KIND,
DECK_KEY,
parseBatchResult,
useDeck,
useDecide,
useQuestLog,
useStartBatch,
type Choice,
} from './api.ts'
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
import styles from './DiscoverPage.module.css'
// isEditable says whether a key press belongs to a field being typed in,
// which the deck's ← / → keys leave alone.
function isEditable(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)
)
}
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
// of the dinners in the Quest log; the Quest log sheet replaces this list.
function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
<>
<button
type="button"
className={styles.questLogButton}
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
{dinners.length === 0 ? (
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
) : (
<ul aria-label="Quest log" className={styles.questLogNames}>
{dinners.map((dinner) => (
<li key={dinner.id}>{dinner.name}</li>
))}
</ul>
)}
</Card>
)}
</>
)
}
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. Every decision — the buttons, the ← / →
// keys, Retry — goes through submit, which does nothing while one is in
// flight. The lock is a ref, set before the decision is sent, so a second
// press before the next render cannot send another.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
const top = deck.data?.[0]
const busy = decision.isPending
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
// The batch's result as far as it has gone, or why it cannot be read.
const batch = useMemo(() => {
if (job.result === null) return { stored: 0, error: null }
try {
return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null }
} catch (error) {
return { stored: 0, error: error as Error }
}
}, [job.result, startBatch.data?.job_id])
// The job's failure, the latest poll's failure, an answer that is not the
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
const { mutate: mutateDecision } = decision
const deciding = useRef(false)
const submit = useCallback(
(variables: { id: string; choice: Choice }) => {
if (deciding.current) return
deciding.current = true
mutateDecision(variables, {
onSettled: () => {
deciding.current = false
},
})
},
[mutateDecision],
)
const decide = useCallback(
(choice: Choice) => {
if (top !== undefined) submit({ id: top.id, choice })
},
[top, submit],
)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (isEditable(event.target)) return
if (event.key === 'ArrowLeft') decide('pass')
else if (event.key === 'ArrowRight') decide('try')
else return
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [decide])
// The batch stores its Side quests as it goes, so the deck is read again
// whenever it reports more, and once it ends.
const { stored } = batch
useEffect(() => {
if (job.status === undefined) return
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
}, [queryClient, job.status, stored])
let body
if (deck.isError) {
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
{DECK_DEPTH.map((depth) => (
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
))}
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
{jobRunning && batchError !== null && (
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
)}
</section>
)
} else if (top === undefined) {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
New side quests arrive Sunday.
</EmptyState>
</>
)
} else {
body = (
<>
<div className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} />
))}
</div>
<div className={styles.actions}>
<IconButton label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<Icon icon={X} size="nav" />
</IconButton>
<IconButton label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <QuestLogPill dinners={questLog.data} />}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
</div>
)
}
-156
View File
@@ -1,156 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's.
export const DECK_KEY = ['suggestions', 'deck'] as const
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
// SideQuest is a pending suggestion as the deck shows it. photo_id is the
// stored photo's id, from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
reason: string
}
// Choice is the household's decision on a Side quest: Try it or Pass.
export type Choice = 'try' | 'pass'
// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job
// running it, which GET /api/v1/jobs/{id} reports on.
export type Batch = { batch_id: string; job_id: string }
// BATCH_JOB_KIND is the kind of the job running a suggestion batch.
export const BATCH_JOB_KIND = 'suggestion_batch'
// BatchResult is the suggestion batch job's result, published as it goes.
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
export type QuestLogDinner = { id: string; name: string }
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
// parseBatchResult checks a suggestion batch job's result, which useJob
// passes on unread.
export function parseBatchResult(where: string, body: unknown): BatchResult {
if (
!isRecord(body) ||
typeof body.batch_id !== 'string' ||
typeof body.stage !== 'string' ||
!Array.isArray(body.suggestion_ids) ||
!body.suggestion_ids.every((id) => typeof id === 'string') ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not a suggestion batch`)
}
return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped }
}
function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
throw new Error(`${where}: response is not a suggestion`)
}
return {
id: body.id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
reason: body.reason,
}
}
// useDeck is the pending Side quests, oldest first (GET
// /api/v1/suggestions/deck).
export function useDeck() {
const { apiFetch } = useApi()
return useQuery({
queryKey: DECK_KEY,
queryFn: async () => {
const where = `GET ${DECK_PATH}`
const body = await apiFetch<unknown>(DECK_PATH)
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion))
},
})
}
// useQuestLog is the dinners in the Quest log, in the server's order (GET
// /api/v1/dinners?status=quest_log).
export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
queryKey: QUEST_LOG_KEY,
queryFn: async (): Promise<QuestLogDinner[]> => {
const where = `GET ${QUEST_LOG_PATH}`
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
return { id: dinner.id, name: dinner.name }
})
},
})
}
// useStartBatch asks for more Side quests now (POST
// /api/v1/suggestions/batches). The server answers with the batch already
// running when there is one.
export function useStartBatch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async (): Promise<Batch> => {
const body = await apiFetch<unknown>(BATCHES_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') {
throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
}
return { batch_id: body.batch_id, job_id: body.job_id }
},
})
}
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
// Quest log again. The answer must be that suggestion, decided that way.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (
!isRecord(body) ||
!isRecord(body.suggestion) ||
body.suggestion.id !== id ||
body.suggestion.status !== DECISION_STATUS[choice]
) {
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
}
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
},
})
}
-19
View File
@@ -29,20 +29,6 @@
height: 62px;
}
/* A shown label sits 6 px under the circle, and the button grows to hold it. */
.labelled {
flex-direction: column;
gap: 6px;
width: auto;
min-width: 62px;
height: auto;
min-height: 62px;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
}
.face {
display: inline-flex;
align-items: center;
@@ -71,11 +57,6 @@
color: var(--color-text);
}
.neutral {
background: var(--color-neutral-100);
color: var(--color-text);
}
.accent {
background: var(--color-accent);
color: var(--on-accent);
-18
View File
@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { declaredStyle } from '../test/cssRules.ts'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
@@ -32,21 +31,4 @@ describe('IconButton', () => {
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
it('shows its label as an 11 px caption under the circle, which names it', () => {
render(
<IconButton label="Pass" size={62} tone="neutral" showLabel>
<svg />
</IconButton>,
)
const button = screen.getByRole('button', { name: 'Pass' })
expect(button).not.toHaveAttribute('aria-label')
expect(button).toHaveTextContent('Pass')
expect(getComputedStyle(button).fontSize).toBe('11px')
expect(getComputedStyle(button).flexDirection).toBe('column')
expectMinHitArea(button)
const face = button.firstElementChild!
expect(hitArea(face)).toEqual({ width: 62, height: 62 })
expect(declaredStyle(face).background).toBe('var(--color-neutral-100)')
})
})
+5 -11
View File
@@ -2,38 +2,32 @@ import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
export type IconButtonTone = 'surface' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
// Shows the label as an 11 px caption under the circle, which then names
// the button.
showLabel?: boolean
}
// Round icon button, named by its label and showing it only with showLabel.
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
// round action button with --shadow-md.
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
// the 62 px size is the round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
showLabel = false,
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
const classes = [styles.button, styles[`size${size}`], showLabel && styles.labelled, className].filter(Boolean).join(' ')
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
return (
<button type={type} aria-label={showLabel ? undefined : label} className={classes} {...rest}>
<button type={type} aria-label={label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
{showLabel && label}
</button>
)
}