Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8046060a | ||
|
|
886aa5e9c2 | ||
|
|
e8ec2dac22 | ||
|
|
6127c5c2d1 | ||
|
|
fd087e0ba3 | ||
|
|
ecf7c6be89 |
+44
-4
@@ -31,6 +31,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"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/shopping"
|
||||
@@ -130,7 +131,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||
// The scheduler is made before the services whose changes wake it.
|
||||
sched, err := scheduler.New(db, settings, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,17 +199,46 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sch := range planSvc.ReminderSchedules() {
|
||||
if err := sched.Register(sch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
purge, err := harness.PurgeSchedule(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(purge); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The job workers and the notification worker stop, and
|
||||
// are waited for, before the database closes.
|
||||
// worker runs. The job workers, the notification worker and the
|
||||
// scheduler stop, and are waited for, before the database closes.
|
||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopWorkers()
|
||||
sched.Wait()
|
||||
runner.Wait()
|
||||
notifier.Wait()
|
||||
}()
|
||||
@@ -219,6 +254,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// The scheduler starts last: its catch-up may enqueue jobs and
|
||||
// notifications, so their workers must already run.
|
||||
if err := sched.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
|
||||
+170
-46
@@ -259,6 +259,29 @@ automatic** (D-021).
|
||||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||||
pending with nothing left to ground it.
|
||||
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
|
||||
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
|
||||
waiting for it; while one is queued or running it enqueues nothing and
|
||||
answers with that job's id, so two passes never overlap, across restarts
|
||||
too. The kind is refused by `POST /api/v1/jobs`. The job
|
||||
grounds, one at a time with `Ground` on its name, every unarchived dinner
|
||||
that is `failed` with no photo and no pasted `recipe_url` — grounded,
|
||||
`manual` and `pending` dinners are never listed, and a pasted link is
|
||||
never replaced by a search. Success sets grounding_status `grounded` as
|
||||
the `ground_dinner` job does (the recipe keeps its source URL and site);
|
||||
failure leaves the dinner `failed` with the new `grounding_error`, for
|
||||
the fix-recipe flow. One dinner's failure never stops the others; a
|
||||
dinner renamed, archived or grounded by hand meanwhile is left as it is.
|
||||
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
|
||||
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
|
||||
fails is `failed` with `grounding_error` and left as it was; the others
|
||||
still run, then the job fails with `internal_error`, keeping that result.
|
||||
The responses returning a `dinners.Dinner` representation (the list; the
|
||||
create, update, archive, favorite and grounding responses; the idea
|
||||
responses) carry `photo_backfill_eligible`, and
|
||||
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
|
||||
that dinner, computed by the same condition it lists dinners by, so a
|
||||
client offers a search only where one runs.
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
@@ -309,20 +332,24 @@ automatic** (D-021).
|
||||
`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`,
|
||||
left. A Side quest no recipe grounds is dropped. A `replenish` batch
|
||||
stores each round's grounded ones before the next; a `scheduled` or
|
||||
`manual` batch, which notifies, holds them and stores them all in one
|
||||
transaction once its last round is done. Each is stored as 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
|
||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||||
failed `replenish` batch's 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
|
||||
@@ -480,15 +507,13 @@ automatic** (D-021).
|
||||
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||||
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||||
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||
- Rotation check (`plan.Service`, D-021). This release provides the
|
||||
`MarkTried` service method and the rotation review read and decide APIs
|
||||
below. The `rotation_check` schedule that is to call `MarkTried` for the
|
||||
household-local day just past is planned (Feature 20, the scheduler) and
|
||||
not wired yet: nothing in production calls `MarkTried`, so no Rotation
|
||||
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
||||
a dinner already checked for that date is left as it is, so it is
|
||||
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
||||
calls `MarkTried` for the household-local day just past (see Scheduler).
|
||||
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
||||
emits its `rotation_review`, in one transaction;
|
||||
a dinner already checked for that date is left as it is and not
|
||||
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||
@@ -533,8 +558,8 @@ automatic** (D-021).
|
||||
received.
|
||||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||||
clearing the `suggestion_batches` and `week_plans` references first. The
|
||||
harness does not call it: the scheduler's daily `ai_runs` purge
|
||||
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
|
||||
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
||||
its fire time less `RunRetention`.
|
||||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||||
takes a scope key, and concurrent runs of one skill with one scope share a
|
||||
single in-flight run and its result, error included (single-flight); runs
|
||||
@@ -649,7 +674,8 @@ job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
||||
`Freeze` for last week, judging the week ended by its fire time.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -775,16 +801,92 @@ purge are fixed and not configurable.
|
||||
|
||||
| Schedule | When | Does |
|
||||
| --- | --- | --- |
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
|
||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
|
||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
||||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
||||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||||
|
||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||||
run function; settings changes reach it through `SettingsService.Subscribe`
|
||||
and move the next fire time without a restart. Each fire time has a period
|
||||
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
||||
fire date for a weekly one, or of the week after for `WeeklyForNextWeek`
|
||||
(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim;
|
||||
a period already claimed is skipped, so a period runs at most once and a
|
||||
failed run is not retried. A claim that fails is not skipped: the pass is
|
||||
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
|
||||
change is recorded with the instant it was saved, and fire times up to that
|
||||
instant follow the old rule and later ones the new, however late the
|
||||
scheduler applies it. At boot, and whenever the scheduler wakes late,
|
||||
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
|
||||
processed oldest first, each running once. A most recent fire time that
|
||||
passed longer ago is claimed without running (logged as missed) so it
|
||||
never runs later; older ones are not looked at. DST
|
||||
follows `household.LocalTime`: a skipped time fires later by the gap, a
|
||||
repeated time fires once, at its first occurrence.
|
||||
|
||||
Each schedule is built by the service that does its work, as a
|
||||
`scheduler.Schedule` value, and registered in `cmd/server` before the
|
||||
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
|
||||
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
|
||||
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
|
||||
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
|
||||
batch is already pending or running the period starts none and logs it.
|
||||
`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week
|
||||
after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls
|
||||
`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week`
|
||||
job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored
|
||||
draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
|
||||
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
||||
has a plan, in any status, it enqueues nothing; when a draft of the week
|
||||
is already queued or running it joins it. Either way it logs it.
|
||||
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
|
||||
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
|
||||
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
|
||||
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
|
||||
for each date it reads the plan and fires 15 minutes
|
||||
(`plan.StartReminderLead`) before the day's `start_by` (the same
|
||||
computation the plan API shows) when the dinner is a crock-pot dinner
|
||||
whose `start_by` is earlier than that day's `reminder_time`; its run
|
||||
calls `EmitStartReminder(period.Date)`, which checks the same again. A
|
||||
date whose week has no plan emits neither and logs it. A `DailyAt` fire
|
||||
time is looked up from the data on every pass. A committed change that
|
||||
can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService`
|
||||
and `dinners.NewService` as `wake`): a plan swap or stored draft, and a
|
||||
dinner's method or minutes changed by edit or grounding. The next pass
|
||||
comes at once, so a crock-pot dinner swapped in for tonight is still
|
||||
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
||||
(`maxWait`); a lookup that fails is logged and repeated every
|
||||
`ClaimRetry`.
|
||||
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
|
||||
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
|
||||
names the coming week. Its run calls `Autosend(period.Week)`, which posts
|
||||
with `SendToChat(week, false)`, the household's own send, only when a
|
||||
webhook is stored, `list_ready`'s Chat channel is on
|
||||
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
|
||||
nor with a send unconfirmed; otherwise it posts nothing and logs why,
|
||||
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
||||
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||||
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
||||
the fire date's, and a week with no Supply run freezes nothing and logs
|
||||
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
|
||||
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
|
||||
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
|
||||
the fire date, which emits one `rotation_review` per Rotation check it
|
||||
opens; it never makes a dinner a Regular, and a day with no Quest log
|
||||
dinner planned opens none and logs the count.
|
||||
`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
||||
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
||||
time.
|
||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
`SetClock` is visible to tests.
|
||||
|
||||
## Notifications
|
||||
|
||||
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
||||
@@ -793,25 +895,43 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||
change and its notification commit together or not at all:
|
||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
|
||||
AI's draft of a week. Only the first draft of a week notifies: a
|
||||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
||||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
||||
week. Only the first draft of a week notifies: a
|
||||
redraft (Rebuild week) has the same key, and a manual swap never
|
||||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||||
or "The plan for the week of Oct 26") and its body the drafted
|
||||
dinners Monday to Sunday.
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||
after Push to Calendar; remaking it does not notify.
|
||||
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
||||
`manual` suggestion batch first adds Side quests to the deck, in the
|
||||
transaction storing them, so they are announced even when a later
|
||||
round or recording the batch's end fails; it names that round's Side
|
||||
quests, and a later round's emit stores nothing. A `replenish` batch
|
||||
never notifies.
|
||||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
||||
was scheduled for (carried in its job, so a batch caught up late is
|
||||
still keyed by its own week), or `new_suggestions:{batchId}` for a
|
||||
`manual` one (URL `/discover`), once, when the batch's last round is
|
||||
done, in the one transaction that stores all its Side quests, so they
|
||||
and their notification commit together or not at all. Its title
|
||||
counts every Side quest the batch stored ("5 new Side quests are in")
|
||||
and its body names them. A batch that fails at any round, as when
|
||||
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
|
||||
failure is in `ai_runs`), stores no Side quest and sends nothing, so
|
||||
no stored Side quest goes unannounced. A `replenish` batch never
|
||||
notifies. This is the only place `new_suggestions` is emitted; the
|
||||
`suggestions_weekly` schedule does not emit it itself.
|
||||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
||||
on the date and emits nothing for a week with no plan. The scheduler
|
||||
calls it at `reminder_time` (Feature 20).
|
||||
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
||||
naming the dinner planned on the date; it emits nothing for a week
|
||||
with no plan. The `day_of_reminder` schedule calls it at
|
||||
`reminder_time`.
|
||||
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
|
||||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||
schedule calls it 15 minutes before `start_by`.
|
||||
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
|
||||
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
|
||||
Keep it in rotation?", once per Rotation check it opens. The
|
||||
`rotation_check` schedule calls it at `review_time` for yesterday.
|
||||
- Delivery goes through `notification_outbox`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
@@ -1068,7 +1188,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
request. 404 when the week has no Supply run, 409
|
||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||
`shopping_chat_autosend` calls (Feature 20).
|
||||
`shopping_chat_autosend` calls, through `Autosend`.
|
||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
@@ -1098,6 +1218,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
```json
|
||||
{
|
||||
"dateLabel": "Friday, Oct 2",
|
||||
"timezone": "America/Chicago",
|
||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||
"sideQuestCount": 10,
|
||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||
@@ -1107,8 +1228,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||
when the week has no plan; it changes at the household's local
|
||||
midnight. `photo` and `minutes_total` are null when unknown.
|
||||
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
|
||||
midnight, so the PWA reads Home again at the next local midnight of
|
||||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
||||
null when unknown. `sideQuestCount` is the pending cards of the deck,
|
||||
read without writing (`suggest.PendingCount`): a Home read never fails
|
||||
orphaned batches the way a `suggest.Deck` read does.
|
||||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||
the current week's (`shopping.Get`); with neither, it is the current
|
||||
week with `ready` false. `recentUpdates.unread` is true when a
|
||||
@@ -1123,7 +1247,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
one update):
|
||||
|
||||
```json
|
||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||
```
|
||||
|
||||
Every field comes from the stored payload, which never holds a webhook
|
||||
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
StatusProviderError RunStatus = "provider_error"
|
||||
)
|
||||
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge calls
|
||||
// Purge with time.Now().Add(-RunRetention).
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge,
|
||||
// PurgeSchedule, calls Purge with its fire time less RunRetention.
|
||||
const RunRetention = 30 * 24 * time.Hour
|
||||
|
||||
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
||||
@@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second
|
||||
// Purge deletes the ai_runs rows created before olderThan and returns how
|
||||
// many it deleted. The suggestion_batches and week_plans references to
|
||||
// those rows are cleared first, in the same transaction.
|
||||
//
|
||||
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
|
||||
// (Feature 20) must call it with time.Now().Add(-RunRetention).
|
||||
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
||||
if olderThan.IsZero() {
|
||||
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
|
||||
// It is fixed, not a setting.
|
||||
const PurgeTime = "03:00"
|
||||
|
||||
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
|
||||
// PurgeTime it deletes the ai_runs rows created more than RunRetention
|
||||
// before the fire time with Purge, its period scheduler.Daily keyed
|
||||
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
|
||||
// than RunRetention; one that fails is the period's error and is not
|
||||
// retried, and the next day's purge deletes what it left.
|
||||
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
|
||||
if st == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
|
||||
}
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.AIRunsPurge,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(PurgeTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
cutoff := p.FireAt.Add(-RunRetention)
|
||||
n, err := Purge(ctx, st, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", week),
|
||||
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
|
||||
}
|
||||
for _, ev := range events {
|
||||
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
||||
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
// The cards' "Open" buttons link under the public URL to the event's week.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
|
||||
+40
-18
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: planTitle(week, thisWeek),
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
@@ -59,48 +60,65 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||
// name, planned on date.
|
||||
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
||||
// own call to action.
|
||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||
Title: "Tonight's dinner",
|
||||
Title: "Tonight: " + name + " · Recipe →",
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// StartReminder is the day_of_reminder event, keyed
|
||||
// day_of_reminder:start:{date}, reminding the household to start the
|
||||
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
|
||||
// a time in the household zone: "Start the crock pot by 9:30 am".
|
||||
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
|
||||
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
|
||||
Body: name,
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||
// made in the week starting on week, naming its Side quests.
|
||||
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
|
||||
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
|
||||
// one, of the week starting on week: how many new Side quests there are,
|
||||
// and their names.
|
||||
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.NewSuggestions,
|
||||
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||
Title: "New Side quests are in",
|
||||
Key: string(notify.NewSuggestions) + ":" + id,
|
||||
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
|
||||
Body: nameList(names),
|
||||
URL: "/discover",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// RotationReview is the rotation_review event asking whether the dinner
|
||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||
// Regular.
|
||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||
// RotationReview is the rotation_review event, keyed
|
||||
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
||||
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
||||
// in rotation?". It opens Home, where the Rotation check card asks.
|
||||
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.RotationReview,
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||
Title: "Keep it in rotation?",
|
||||
Body: "You tried " + name + ". Make it a Regular?",
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
||||
Title: "How was " + name + "? Keep it in rotation?",
|
||||
URL: "/",
|
||||
Week: week,
|
||||
Week: household.WeekStart(planDate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +189,13 @@ func planTitle(week, thisWeek store.LocalDate) string {
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
// and the event's call to action with an arrow. A title ending in an arrow,
|
||||
// with no body, carries its own call to action and is the message as it is.
|
||||
func Text(p notify.Payload) string {
|
||||
text := p.Title
|
||||
if p.Body == "" && strings.HasSuffix(text, "→") {
|
||||
return text
|
||||
}
|
||||
if p.Body != "" {
|
||||
if endsSentence(text) {
|
||||
text += " "
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -38,7 +39,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
@@ -48,7 +49,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
@@ -58,7 +59,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for next week",
|
||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "Next week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||
@@ -68,7 +69,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for a later week",
|
||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||
key: "plan_ready:2026-10-26",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-26",
|
||||
week: "2026-10-26",
|
||||
title: "The plan for the week of Oct 26 is ready",
|
||||
body: "Tacos",
|
||||
@@ -78,7 +79,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
@@ -88,7 +89,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
@@ -100,9 +101,19 @@ func TestMessages(t *testing.T) {
|
||||
key: "day_of_reminder:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Tonight's dinner",
|
||||
body: "Tacos",
|
||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
||||
title: "Tonight: Tacos · Recipe →",
|
||||
body: "",
|
||||
chatT: "Tonight: Tacos · Recipe →",
|
||||
},
|
||||
{
|
||||
name: "crock-pot start reminder",
|
||||
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
|
||||
key: "day_of_reminder:start:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Start the crock pot by 9:30 am",
|
||||
body: "Beef Chili",
|
||||
chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions",
|
||||
@@ -110,19 +121,29 @@ func TestMessages(t *testing.T) {
|
||||
key: "new_suggestions:01J9BATCH",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "New Side quests are in",
|
||||
title: "5 new Side quests are in",
|
||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions of a scheduled week",
|
||||
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
|
||||
key: "new_suggestions:2026-W42",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "1 new Side quest is in",
|
||||
body: "Pho",
|
||||
chatT: "1 new Side quest is in: Pho. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||
key: "rotation_review:01J9DINNER",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
|
||||
key: "rotation_review:01J9DINNER:2026-10-14",
|
||||
url: "/",
|
||||
week: "2026-10-12",
|
||||
title: "Keep it in rotation?",
|
||||
body: "You tried Lemon orzo. Make it a Regular?",
|
||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||
title: "How was Lemon orzo? Keep it in rotation?",
|
||||
body: "",
|
||||
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
|
||||
},
|
||||
} {
|
||||
ev := tc.ev
|
||||
|
||||
@@ -214,6 +214,26 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
|
||||
SELECT id
|
||||
FROM jobs
|
||||
WHERE kind = ?1
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.dinner_id') IS NULL
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
// running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
// there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE id = ?
|
||||
@@ -344,6 +364,58 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = ?8
|
||||
`
|
||||
|
||||
type GroundFailedDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records a photo backfill's grounding of a dinner it listed, while the
|
||||
// dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
// the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
// renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
// member already set.
|
||||
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundFailedDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
@@ -444,6 +516,64 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND id = COALESCE(?1, id)
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
|
||||
// The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
// photo and no pasted recipe_url (the household's own link is never
|
||||
// replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
// the list to that dinner. Grounded, manual and pending dinners are never
|
||||
// listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT 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, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
@@ -560,6 +690,40 @@ func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStar
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_error = ?1,
|
||||
updated_at = ?2
|
||||
WHERE id = ?3
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = ?4
|
||||
`
|
||||
|
||||
type RefailDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records why a photo backfill could not ground a dinner it listed, under
|
||||
// the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
|
||||
UPDATE dinners
|
||||
SET is_favorite = ?,
|
||||
|
||||
@@ -24,6 +24,9 @@ type Querier interface {
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
// Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
// this caller owns the period, 0 rows means it was already claimed.
|
||||
ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error)
|
||||
// Clears every day's calendar event id: none is on the chosen calendar.
|
||||
ClearCalendarEvents(ctx context.Context, updatedAt string) error
|
||||
// Records that Google refuses the stored grant: clears the refresh token and
|
||||
@@ -114,6 +117,10 @@ type Querier interface {
|
||||
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
|
||||
// The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
// running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
// there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
@@ -172,6 +179,13 @@ type Querier interface {
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records a photo backfill's grounding of a dinner it listed, while the
|
||||
// dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
// the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
// renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
// member already set.
|
||||
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
@@ -203,6 +217,12 @@ type Querier interface {
|
||||
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||
// date first, with their dinners.
|
||||
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
|
||||
// The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
// photo and no pasted recipe_url (the household's own link is never
|
||||
// replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
// the list to that dinner. Grounded, manual and pending dinners are never
|
||||
// listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error)
|
||||
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||
// no dinner is left out.
|
||||
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
|
||||
@@ -304,6 +324,9 @@ type Querier interface {
|
||||
// Records a new draft over plan id: its status and note, raising its
|
||||
// revision.
|
||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||
// Records why a photo backfill could not ground a dinner it listed, under
|
||||
// the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
|
||||
// Records a generation of list id from plan plan_id.
|
||||
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
||||
// Records a failed delivery and when to try again. The lease must still be
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: schedule_runs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const claimScheduleRun = `-- name: ClaimScheduleRun :execrows
|
||||
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (schedule, period_key) DO NOTHING
|
||||
`
|
||||
|
||||
type ClaimScheduleRunParams struct {
|
||||
ID string
|
||||
Schedule string
|
||||
PeriodKey string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
// this caller owns the period, 0 rows means it was already claimed.
|
||||
func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, claimScheduleRun,
|
||||
arg.ID,
|
||||
arg.Schedule,
|
||||
arg.PeriodKey,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindBackfillPhotos is the job that grounds, by name, the dinners left
|
||||
// without a photo: those whose grounding failed and that have no pasted
|
||||
// recipe_url (ListPhotoBackfillDinners).
|
||||
const KindBackfillPhotos = "backfill_dinner_photos"
|
||||
|
||||
// Photo backfill outcomes for one dinner.
|
||||
const (
|
||||
BackfillGrounded = "grounded"
|
||||
BackfillFailed = "failed"
|
||||
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
|
||||
// while the backfill grounded it, so the backfill left it as it is.
|
||||
BackfillSkipped = "skipped"
|
||||
)
|
||||
|
||||
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
|
||||
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
|
||||
// and RefailDinnerGrounding write under, for one dinner. The dinner is
|
||||
// unarchived, failed, with no photo and no pasted recipe_url.
|
||||
func PhotoBackfillEligible(r db.Dinner) bool {
|
||||
return r.Status != StatusArchived &&
|
||||
r.GroundingStatus == GroundingFailed &&
|
||||
!r.PhotoID.Valid &&
|
||||
!r.RecipeUrl.Valid
|
||||
}
|
||||
|
||||
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
|
||||
// backfill to that dinner; empty backfills every dinner without a photo.
|
||||
type BackfillJob struct {
|
||||
DinnerID string `json:"dinner_id,omitempty"`
|
||||
}
|
||||
|
||||
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
|
||||
// set only for BackfillFailed: the Reason recorded as its grounding_error.
|
||||
type BackfillOutcome struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// backfillResult is the KindBackfillPhotos result: the outcome of each
|
||||
// dinner the backfill tried, in the order it tried them.
|
||||
type backfillResult struct {
|
||||
Dinners []BackfillOutcome `json:"dinners"`
|
||||
}
|
||||
|
||||
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
|
||||
// without a photo and returns its id, without waiting for it. While a full
|
||||
// backfill is queued or running it enqueues nothing and returns that job's
|
||||
// id instead, so two passes never ground the same dinner at once; the jobs
|
||||
// table holds the claim, so it holds across restarts too.
|
||||
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
|
||||
var id string
|
||||
created := false
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("dinners: read active photo backfill: %w", err)
|
||||
default:
|
||||
id = active
|
||||
return nil
|
||||
}
|
||||
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created {
|
||||
s.backfillJob.Wake()
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// errBackfillNotRecorded fails a photo backfill that could not record the
|
||||
// outcome of one or more of its dinners.
|
||||
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
|
||||
|
||||
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
|
||||
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
|
||||
// candidate whose photo caches and whose ingredients normalize grounds it,
|
||||
// with the recipe's source URL and site kept as any grounding keeps them.
|
||||
// Success records the recipe and photo with grounding_status grounded;
|
||||
// failure leaves the dinner failed with the new Reason as its
|
||||
// grounding_error, for the household to fix its recipe by hand. One
|
||||
// dinner's failure, its search's, model's or database write's, never stops
|
||||
// the others. A failure to list the dinners, or the job's context ending,
|
||||
// fails the job at once. A write that failed fails the job once every
|
||||
// dinner has been tried, as a grounding job's failed write does, with the
|
||||
// outcomes published as its result, so the job never reports done for a
|
||||
// dinner whose outcome is not stored.
|
||||
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
|
||||
q := db.New(s.st.Reader)
|
||||
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
|
||||
if err != nil {
|
||||
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
|
||||
}
|
||||
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
|
||||
grounded, unrecorded := false, false
|
||||
for _, row := range rows {
|
||||
out, recorded, err := s.backfillDinner(ctx, row)
|
||||
if err != nil {
|
||||
return backfillResult{}, err
|
||||
}
|
||||
grounded = grounded || out.Outcome == BackfillGrounded
|
||||
unrecorded = unrecorded || !recorded
|
||||
res.Dinners = append(res.Dinners, out)
|
||||
}
|
||||
if grounded {
|
||||
s.wake()
|
||||
}
|
||||
if unrecorded {
|
||||
if err := publish(res); err != nil {
|
||||
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
|
||||
}
|
||||
return backfillResult{}, errBackfillNotRecorded
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
|
||||
// runBackfillJob describes, and reports whether its outcome was recorded.
|
||||
// Its error is only the job's context ending.
|
||||
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
|
||||
out := BackfillOutcome{DinnerID: row.ID}
|
||||
w := db.New(s.st.Writer)
|
||||
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return BackfillOutcome{}, false, ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
reason := reasonFor(err, false)
|
||||
// Only the reason is logged: the error may quote a dependency's
|
||||
// response, which jobs never store, return or log.
|
||||
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
|
||||
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
})
|
||||
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
|
||||
return out, recorded, nil
|
||||
}
|
||||
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
})
|
||||
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
|
||||
return out, recorded, nil
|
||||
}
|
||||
|
||||
// backfillRecorded is out once the backfill recorded outcome (with reason,
|
||||
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
|
||||
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
|
||||
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
|
||||
// and not recorded.
|
||||
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
|
||||
switch {
|
||||
case err != nil:
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
|
||||
out.Outcome, reason = BackfillFailed, ReasonGroundingError
|
||||
case n == 0:
|
||||
out.Outcome, reason = BackfillSkipped, ""
|
||||
default:
|
||||
out.Outcome = outcome
|
||||
}
|
||||
if reason != "" {
|
||||
out.Reason = &reason
|
||||
}
|
||||
return out, err == nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"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/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// failedDinner stores a Regular named name whose grounding failed with
|
||||
// no_recipe_found, without a photo or recipe_url, and returns its id.
|
||||
func (e *env) failedDinner(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: dinners.StatusRegular,
|
||||
Origin: "regular",
|
||||
GroundingStatus: dinners.GroundingPending,
|
||||
UpdatedBy: sql.NullString{String: member, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.ID
|
||||
}
|
||||
|
||||
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
|
||||
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
|
||||
}
|
||||
|
||||
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
|
||||
t.Helper()
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
if string(gj) != string(wj) {
|
||||
t.Fatalf("outcomes = %s, want %s", gj, wj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
// curryA ranks first but its photo is gone; curryB's photo caches.
|
||||
delete(e.images.JPEGs, "https://a.example/curry.jpg")
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
id := e.failedDinner(t, "Chicken curry")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
|
||||
|
||||
row := e.dinner(t, id)
|
||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
|
||||
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
|
||||
}
|
||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
|
||||
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
|
||||
}
|
||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
||||
t.Errorf("searched %q, want the dinner's name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
|
||||
// One reply: the second dinner must reuse the recipe the first stored.
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
first := e.failedDinner(t, "Chicken curry")
|
||||
second := e.failedDinner(t, "Curry chicken")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
||||
outcome(first, dinners.BackfillGrounded, nil),
|
||||
outcome(second, dinners.BackfillGrounded, nil),
|
||||
})
|
||||
a, b := e.dinner(t, first), e.dinner(t, second)
|
||||
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
|
||||
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
|
||||
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("normalize_recipe ran %d times, want 1", n)
|
||||
}
|
||||
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
|
||||
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
// Listed by normalized name: both failures come before the curry.
|
||||
broken := e.failedDinner(t, "Broken search")
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
absent := e.failedDinner(t, "Abalone surprise")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
||||
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
||||
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
|
||||
outcome(curry, dinners.BackfillGrounded, nil),
|
||||
})
|
||||
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
|
||||
row := e.dinner(t, id)
|
||||
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
|
||||
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
|
||||
}
|
||||
}
|
||||
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
|
||||
t.Errorf("curry = %+v, want grounded with a photo", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *c.GroundingJobID)
|
||||
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
|
||||
t.Fatalf("dinner = %+v, want grounded", row)
|
||||
}
|
||||
// A failed dinner with a pasted link keeps that link for a fix by hand.
|
||||
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *linked.GroundingJobID)
|
||||
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
|
||||
t.Fatalf("linked dinner = %+v, want failed", row)
|
||||
}
|
||||
archived := e.failedDinner(t, "Old stew")
|
||||
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
searched := len(e.search.Queries())
|
||||
|
||||
id, err := e.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.waitJob(t, id)
|
||||
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
|
||||
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
|
||||
}
|
||||
if got := e.search.Queries()[searched:]; len(got) != 0 {
|
||||
t.Errorf("backfill searched %q, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
|
||||
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
|
||||
// backfill grounds and for each kind it never does.
|
||||
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *grounded.GroundingJobID)
|
||||
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *linked.GroundingJobID)
|
||||
failed := e.failedDinner(t, "Abalone surprise")
|
||||
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
shown := map[string]dinners.Dinner{archived.ID: archived}
|
||||
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range regulars {
|
||||
shown[d.ID] = d
|
||||
}
|
||||
q := db.New(e.st.Reader)
|
||||
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
|
||||
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, ok := shown[id]
|
||||
if !ok {
|
||||
t.Fatalf("dinner %s not shown", id)
|
||||
}
|
||||
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
|
||||
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
other := e.failedDinner(t, "Abalone surprise")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
|
||||
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("other dinner = %+v, want left failed", row)
|
||||
}
|
||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
||||
t.Errorf("searched %q, want only the dinner's name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
id := e.failedDinner(t, "Chicken curry")
|
||||
gate := make(chan struct{})
|
||||
e.search.Gate = gate
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
|
||||
done := make(chan []dinners.BackfillOutcome)
|
||||
go func() {
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
done <- got
|
||||
}()
|
||||
// The backfill listed the dinner and waits at its search.
|
||||
for len(e.search.Queries()) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(gate)
|
||||
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
|
||||
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("dinner = %+v, want left failed without a photo", row)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
|
||||
// together and returns the job ids they got.
|
||||
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
|
||||
t.Helper()
|
||||
ids := make([]string, n)
|
||||
errs := make([]error, n)
|
||||
release := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-release
|
||||
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
|
||||
}()
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// backfillJobs counts the KindBackfillPhotos jobs in st.
|
||||
func backfillJobs(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
|
||||
t.Parallel()
|
||||
const triggers = 8
|
||||
dataDir := t.TempDir()
|
||||
st := openStore(t, dataDir)
|
||||
e := newUnstartedEnvOn(t, st, dataDir)
|
||||
e.failedDinner(t, "Chicken curry")
|
||||
gate := make(chan struct{})
|
||||
e.search.Gate = gate
|
||||
|
||||
// Queued: simultaneous triggers all get the one pass they enqueued.
|
||||
ids := triggerAtOnce(t, e.svc, triggers)
|
||||
first := ids[0]
|
||||
for _, id := range ids {
|
||||
if id != first {
|
||||
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
|
||||
}
|
||||
}
|
||||
if n := backfillJobs(t, st); n != 1 {
|
||||
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
|
||||
}
|
||||
|
||||
// After a restart the queued pass is still the one: the claim is the
|
||||
// stored job, not the process's memory.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = openStore(t, dataDir)
|
||||
restarted := newUnstartedEnvOn(t, st, dataDir)
|
||||
restarted.search.Gate = gate
|
||||
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
|
||||
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
|
||||
}
|
||||
|
||||
// Running: the pass waits at its search, and simultaneous triggers all
|
||||
// return it.
|
||||
restarted.start(t)
|
||||
for len(restarted.search.Queries()) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
|
||||
if id != first {
|
||||
t.Fatalf("trigger while running = %q, want the running %q", id, first)
|
||||
}
|
||||
}
|
||||
if n := backfillJobs(t, st); n != 1 {
|
||||
t.Fatalf("%d backfill jobs, want 1", n)
|
||||
}
|
||||
|
||||
// Finished: the next trigger starts a new pass.
|
||||
close(gate)
|
||||
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
|
||||
}
|
||||
next, err := restarted.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next == first {
|
||||
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
|
||||
}
|
||||
restarted.waitJob(t, next)
|
||||
}
|
||||
|
||||
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
absent := e.failedDinner(t, "Abalone surprise")
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
|
||||
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
|
||||
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := e.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.waitJob(t, id)
|
||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
|
||||
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
|
||||
}
|
||||
var got struct {
|
||||
Dinners []dinners.BackfillOutcome `json:"dinners"`
|
||||
}
|
||||
if err := json.Unmarshal(job.Result, &got); err != nil {
|
||||
t.Fatalf("result %s: %v", job.Result, err)
|
||||
}
|
||||
// The other dinner was still tried and recorded.
|
||||
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
|
||||
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
||||
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
|
||||
})
|
||||
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("curry = %+v, want left failed without a photo", row)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ type Dinner struct {
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
|
||||
// (PhotoBackfillEligible): false for one with a pasted recipe link,
|
||||
// which a search never replaces.
|
||||
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
|
||||
}
|
||||
|
||||
// NewRegular is a Regular to add: its Name, which the household always
|
||||
@@ -138,6 +143,11 @@ type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
groundJob *jobs.Kind[GroundJob]
|
||||
// backfillJob enqueues KindBackfillPhotos jobs.
|
||||
backfillJob *jobs.Kind[BackfillJob]
|
||||
// wake is called after every committed change to a dinner's method or
|
||||
// minutes, which can move its crock-pot start reminder.
|
||||
wake func()
|
||||
|
||||
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||
@@ -157,10 +167,11 @@ type Service struct {
|
||||
offerSearches map[string]map[*offerSearch]struct{}
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
// and registers the KindGroundDinner job on runner, which must not have
|
||||
// started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
// NewService returns a service over st that grounds dinners with grounder,
|
||||
// calls wake (the scheduler's Wake) after every committed change to a
|
||||
// dinner's method or minutes, and registers the KindGroundDinner and
|
||||
// KindBackfillPhotos jobs on runner, which must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("dinners: nil store")
|
||||
@@ -168,16 +179,26 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
return nil, errors.New("dinners: nil grounder")
|
||||
case runner == nil:
|
||||
return nil, errors.New("dinners: nil job runner")
|
||||
case wake == nil:
|
||||
return nil, errors.New("dinners: nil wake")
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.groundJob = kind
|
||||
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := backfill.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.backfillJob = backfill
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -502,6 +523,9 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if p.Method != nil || p.MinutesTotal != nil {
|
||||
s.wake()
|
||||
}
|
||||
if renamed {
|
||||
updateCommitted()
|
||||
s.clearOffers(row.ID, row.GroundingRevision)
|
||||
@@ -693,6 +717,8 @@ func fromRow(r db.Dinner) Dinner {
|
||||
UpdatedBy: nullString(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
|
||||
PhotoBackfillEligible: PhotoBackfillEligible(r),
|
||||
}
|
||||
if r.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &r.MinutesTotal.Int64
|
||||
|
||||
@@ -74,3 +74,18 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns its outcomes: on an
|
||||
// error, those it last published.
|
||||
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
|
||||
var published backfillResult
|
||||
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
|
||||
published = partial
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return published.Dinners, err
|
||||
}
|
||||
return r.Dinners, nil
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
||||
if n == 0 {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
s.wake()
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||
}
|
||||
|
||||
@@ -577,6 +578,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.wake()
|
||||
s.clearOffers(cur.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
@@ -700,6 +702,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.wake()
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type env struct {
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
model *llm.Fake
|
||||
}
|
||||
|
||||
@@ -104,11 +105,11 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
|
||||
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
|
||||
@@ -50,6 +50,9 @@ func NewService(settings *household.SettingsService, plans *plan.Service, sugges
|
||||
type Home struct {
|
||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||
DateLabel string `json:"dateLabel"`
|
||||
// Timezone is the household's IANA zone, whose next local midnight
|
||||
// is when Home is read again for the new day.
|
||||
Timezone string `json:"timezone"`
|
||||
// Tonight is the dinner planned for today on the current week's plan;
|
||||
// nil when the week has no plan.
|
||||
Tonight *Dinner `json:"tonight"`
|
||||
@@ -92,7 +95,8 @@ type RecentUpdates struct {
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
// Get reads the Home screen at the service's "now".
|
||||
// Get reads the Home screen at the service's "now". It only reads: it
|
||||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
@@ -110,7 +114,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
deck, err := s.suggest.Deck(ctx)
|
||||
sideQuests, err := s.suggest.PendingCount(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||
}
|
||||
@@ -124,8 +128,9 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
}
|
||||
return Home{
|
||||
DateLabel: now.Format("Monday, Jan 2"),
|
||||
Timezone: settings.Timezone,
|
||||
Tonight: tonight,
|
||||
SideQuestCount: len(deck.Suggestions),
|
||||
SideQuestCount: sideQuests,
|
||||
ShoppingList: list,
|
||||
RecentUpdates: RecentUpdates{Unread: unread},
|
||||
}, nil
|
||||
|
||||
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -245,6 +245,7 @@ func TestHomeEmpty(t *testing.T) {
|
||||
h := e.at(t, "2026-10-13 23:59")
|
||||
want := home.Home{
|
||||
DateLabel: "Tuesday, Oct 13",
|
||||
Timezone: zone,
|
||||
ShoppingList: home.ShoppingStatus{
|
||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||
},
|
||||
@@ -289,6 +290,32 @@ func TestSideQuestCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Home writes nothing: a batch still pending whose job is gone,
|
||||
// which a Deck read would fail, is left as it was, and the read succeeds.
|
||||
func TestGetDoesNotWriteBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
created := "2026-10-13T12:00:00.000Z"
|
||||
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Status != "pending" {
|
||||
t.Fatalf("new batch status = %q, want pending", batch.Status)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
|
||||
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
|
||||
}
|
||||
var status, updatedAt string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "pending" || updatedAt != created {
|
||||
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
// The Supply run shown is the upcoming week's when it has one, else the
|
||||
// current week's; the weeks are the household's local weeks.
|
||||
func TestShoppingStatus(t *testing.T) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -293,6 +294,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelOn reports whether the preference of event type ev switches
|
||||
// channel ch on. A type with no preference row is an error.
|
||||
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
|
||||
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, fmt.Errorf("notify: no notification preference for %s", ev)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
|
||||
}
|
||||
return slices.Contains(enabledChannels(pref), ch), nil
|
||||
}
|
||||
|
||||
// enabledChannels returns the channels pref switches on.
|
||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||
var chs []Channel
|
||||
|
||||
@@ -234,6 +234,7 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
||||
return "", err
|
||||
}
|
||||
s.notifier.Wake()
|
||||
s.wake()
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
|
||||
+46
-20
@@ -141,6 +141,9 @@ type Service struct {
|
||||
draft Drafter
|
||||
cal Calendar
|
||||
notifier *notify.Service
|
||||
// wake is called after every committed change to a plan's days, which
|
||||
// can move a crock-pot dinner's start reminder.
|
||||
wake func()
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
@@ -150,11 +153,12 @@ type Service struct {
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, and registers the KindDraftWeek job on
|
||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
||||
// committed change to a plan's days, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||
// enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
||||
// week, are the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -166,12 +170,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil calendar")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("plan: nil notifier")
|
||||
case wake == nil:
|
||||
return nil, errors.New("plan: nil wake")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,10 +239,10 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
|
||||
}
|
||||
|
||||
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
||||
// naming the dinner planned on it, and reports whether it did: a date whose
|
||||
// week has no plan emits nothing. The scheduler calls it at the
|
||||
// household's reminder_time (Feature 20); emitting a date again stores no
|
||||
// second notification.
|
||||
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
|
||||
// whether it did: a date whose week has no plan emits nothing. The
|
||||
// day_of_reminder schedule calls it at the household's reminder_time
|
||||
// (ReminderSchedules); emitting a date again stores no second notification.
|
||||
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||
p, err := s.Get(ctx, household.WeekStart(date))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
@@ -245,15 +251,14 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == date.String() {
|
||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
d, err := p.day(date)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// readPlan reads the plan of the week starting weekStart through q, with
|
||||
@@ -342,10 +347,27 @@ type Draft struct {
|
||||
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
||||
// else it fails with ErrConfirmRequired and enqueues nothing.
|
||||
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
||||
d, _, err := s.startDraft(ctx, weekStart, confirm, false)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// StartScheduledDraft is StartDraft, without confirm, for the plan_draft
|
||||
// schedule: it enqueues the same KindDraftWeek job, or returns the one
|
||||
// queued or running, only while the week starting weekStart has no plan.
|
||||
// A week that has a plan, in any status, is left as it is: nothing is
|
||||
// enqueued and planned is true.
|
||||
func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) {
|
||||
return s.startDraft(ctx, weekStart, false, true)
|
||||
}
|
||||
|
||||
// startDraft is StartDraft, and when onlyUnplanned is set it enqueues
|
||||
// nothing for a week that has a plan and reports planned true.
|
||||
func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
var d Draft
|
||||
planned := false
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
@@ -353,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
case onlyUnplanned:
|
||||
planned = true
|
||||
return nil
|
||||
case cur.Status != StatusDraft && !confirm:
|
||||
return ErrConfirmRequired
|
||||
}
|
||||
@@ -375,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
return Draft{}, false, err
|
||||
}
|
||||
if d.Created {
|
||||
s.draftJob.Wake()
|
||||
}
|
||||
return d, nil
|
||||
return d, planned, nil
|
||||
}
|
||||
|
||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||
@@ -450,6 +475,7 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
s.wake()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,8 +428,8 @@ func TestEmitDayOfReminder(t *testing.T) {
|
||||
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
|
||||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" ||
|
||||
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||
t.Errorf("day_of_reminder row = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// StartReminderLead is how long before a crock-pot dinner's start_by its
|
||||
// start reminder fires.
|
||||
const StartReminderLead = 15 * time.Minute
|
||||
|
||||
// StartReminderKey is the period key prefix of the crock-pot start
|
||||
// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder
|
||||
// schedule.
|
||||
const StartReminderKey = "start"
|
||||
|
||||
// ReminderSchedules are the day_of_reminder schedules. The first fires
|
||||
// every day at the household's reminder_time, period YYYY-MM-DD, and emits
|
||||
// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The
|
||||
// second fires StartReminderLead before the start_by of a crock-pot dinner
|
||||
// whose start_by is earlier than that day's reminder_time, period
|
||||
// start:YYYY-MM-DD of the dinner's date, and emits
|
||||
// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week
|
||||
// has no plan emits neither.
|
||||
func (s *Service) ReminderSchedules() []scheduler.Schedule {
|
||||
return []scheduler.Schedule{
|
||||
{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(settings.ReminderTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
sent, err := s.EmitDayOfReminder(ctx, p.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sent {
|
||||
s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan",
|
||||
"period", p.Key, "date", p.Date.String())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: StartReminderKey,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.DailyAt(s.startReminderAt)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
sent, err := s.EmitStartReminder(ctx, p.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sent {
|
||||
s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder",
|
||||
"period", p.Key, "date", p.Date.String())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock
|
||||
// pot by {time}", for the crock-pot dinner planned on date, and reports
|
||||
// whether it did: it emits nothing unless date's dinner has a start_by
|
||||
// earlier than date's reminder_time. Emitting a date again stores no
|
||||
// second notification.
|
||||
func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||
d, ok, err := s.startReminder(ctx, date)
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// startReminderAt is the start reminder's scheduler.FireAt: the
|
||||
// StartReminderLead before start_by of the dinner date's start reminder,
|
||||
// if it has one.
|
||||
func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) {
|
||||
d, ok, err := s.startReminder(ctx, date)
|
||||
if err != nil || !ok {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return d.StartBy.Add(-StartReminderLead), true, nil
|
||||
}
|
||||
|
||||
// startReminder returns the plan day of date when it has a start reminder:
|
||||
// its dinner has a start_by (a crock-pot dinner of known minutes) earlier
|
||||
// than the household's reminder_time on date. The plan and the settings
|
||||
// come from one read transaction.
|
||||
func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
p, err := readPlan(ctx, q, household.WeekStart(date))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Day{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Day{}, false, err
|
||||
}
|
||||
d, err = p.day(date)
|
||||
if err != nil {
|
||||
return Day{}, false, err
|
||||
}
|
||||
if d.StartBy == nil {
|
||||
return Day{}, false, nil
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
remind, err := household.LocalTime(zone, date, settings.ReminderTime)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err)
|
||||
}
|
||||
return d, d.StartBy.Before(remind), nil
|
||||
}
|
||||
|
||||
// day returns p's day on date; a plan holds all seven days of its week.
|
||||
func (p Plan) day(date store.LocalDate) (Day, error) {
|
||||
for _, d := range p.Days {
|
||||
if d.Date == date.String() {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -79,12 +82,40 @@ type Decided struct {
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// RotationCheckSchedule is the rotation_check schedule: every day at the
|
||||
// household's review_time it runs MarkTried for the day before, so each
|
||||
// Quest log dinner planned yesterday is tried and the household is asked
|
||||
// "Keep it in rotation?". Its period is the fire date, claimed as
|
||||
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
|
||||
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(settings.ReviewTime)
|
||||
},
|
||||
PeriodKey: func(p scheduler.Period) string {
|
||||
return scheduler.RotationCheck + ":" + p.Key
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
yesterday := addDays(p.Date, -1)
|
||||
n, err := s.MarkTried(ctx, yesterday)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
|
||||
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MarkTried moves every dinner in the Quest log planned on date, the
|
||||
// household-local day just past, on a plan of any status, to tried and
|
||||
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||
// dinner already checked for date is left as it is, so a later run never
|
||||
// moves one returned to the Quest log ("We didn't make it") again. It
|
||||
// returns how many dinners it moved.
|
||||
// household-local day just past, on a plan of any status, to tried, opens
|
||||
// a pending Rotation check of it for date and emits its rotation_review
|
||||
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
||||
// dinner already checked for date is left as it is and not notified of
|
||||
// again, so a later run never moves one returned to the Quest log ("We
|
||||
// didn't make it") again. It returns how many dinners it moved.
|
||||
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||
var n int
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
}
|
||||
now := store.Now().String()
|
||||
for _, id := range ids {
|
||||
d, err := q.GetDinner(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read dinner %s: %w", id, err)
|
||||
}
|
||||
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||
@@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
||||
}
|
||||
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
}
|
||||
n = len(ids)
|
||||
return nil
|
||||
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// DraftSchedule is the plan_draft schedule: at the household's
|
||||
// plan_draft_dow and plan_draft_time it drafts the week after the fire
|
||||
// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www
|
||||
// for that week. The draft is the KindDraftWeek job a manual draft runs
|
||||
// (StartScheduledDraft), so a stored draft emits plan_ready keyed
|
||||
// plan_ready:{weekStart} and a draft that fails, as when the AI fails,
|
||||
// stores no plan and emits nothing. A week that already has a plan is left
|
||||
// as it is, and so is a draft of it already queued or running; either way
|
||||
// the period says so.
|
||||
func (s *Service) DraftSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.PlanDraft,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
d, planned, err := s.StartScheduledDraft(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case planned:
|
||||
s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan",
|
||||
"period", p.Key, "week_start", p.Week.String())
|
||||
case !d.Created:
|
||||
s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way",
|
||||
"period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's day_of_reminder schedules on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
|
||||
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
|
||||
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
|
||||
|
||||
// w42 is the Monday of 2026-W42.
|
||||
var w42 = mustLocalDate("2026-10-12")
|
||||
|
||||
// planWeek drafts the plan of the week starting weekStart and waits for it.
|
||||
func (p *planEnv) planWeek(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
p.reply(weekStart)
|
||||
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
|
||||
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// crockPot makes the dinner name a crock-pot dinner of minutes.
|
||||
func (p *planEnv) crockPot(name string, minutes int) {
|
||||
p.e.t.Helper()
|
||||
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminders returns the day_of_reminder notifications stored, oldest
|
||||
// first. The seeded preference sends day_of_reminder to push only, so there
|
||||
// is one row per event.
|
||||
func (e *env) reminders() []notifytest.Row {
|
||||
e.t.Helper()
|
||||
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
|
||||
for _, r := range rows {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// reminderKeys returns the keys of e.reminders().
|
||||
func (e *env) reminderKeys() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range e.reminders() {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// wantReminder fails unless the reminder keyed key was stored with title,
|
||||
// body and the link to the dinner name.
|
||||
func (p *planEnv) wantReminder(key, title, body, name string) {
|
||||
p.e.t.Helper()
|
||||
for _, r := range p.e.reminders() {
|
||||
if r.Key != key {
|
||||
continue
|
||||
}
|
||||
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
|
||||
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
|
||||
if r.Payload != want {
|
||||
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.e.t.Errorf("no reminder %s", key)
|
||||
}
|
||||
|
||||
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
|
||||
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
|
||||
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
|
||||
func TestDayOfReminderNormalNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
|
||||
// earlier than reminder_time gets the start reminder at start_by minus 15
|
||||
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
|
||||
// at reminder_time. A crock-pot night whose start_by is not earlier than
|
||||
// reminder_time gets only the daily one.
|
||||
func TestDayOfReminderCrockPotNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
|
||||
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
|
||||
// at all and then only next week's, nothing is emitted and nothing fails;
|
||||
// next week's crock-pot Monday is reminded on its own day.
|
||||
func TestDayOfReminderEmptyNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "with no plan", e.reminderKeys(), nil)
|
||||
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w43)
|
||||
r.runUntil(at(2026, 10, 18, 23, 59))
|
||||
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
|
||||
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
|
||||
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 9, 15))
|
||||
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
}
|
||||
|
||||
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
|
||||
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
|
||||
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
|
||||
// of 17:00, now earlier than reminder_time, gets its start reminder at
|
||||
// 16:45.
|
||||
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 30) // start by 17:00
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 16, 44))
|
||||
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
|
||||
r.runUntil(at(2026, 10, 12, 16, 45))
|
||||
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
|
||||
r.runUntil(at(2026, 10, 12, 17, 59))
|
||||
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 18, 0))
|
||||
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
|
||||
// of a crock-pot night, within the catch-up window, emit neither again:
|
||||
// each period is already claimed.
|
||||
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
|
||||
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r2.runUntil(at(2026, 10, 12, 16, 30))
|
||||
r2.stop()
|
||||
p2.stop()
|
||||
|
||||
p3 := e.plans(ids)
|
||||
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
|
||||
r3.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
|
||||
// its lookup gives, its period keyed by the schedule's Key and the date;
|
||||
// a lookup that fails fires nothing and is looked up again after
|
||||
// ClaimRetry, then fires its period once.
|
||||
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
var mu sync.Mutex
|
||||
failing := false
|
||||
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if failing {
|
||||
return time.Time{}, false, errors.New("database down")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return at(y, m, day, 9, 15), true, nil
|
||||
}
|
||||
sch := scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: "start",
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
|
||||
Run: rec.run,
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), sch)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
|
||||
mu.Lock()
|
||||
failing = true
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 16))
|
||||
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
|
||||
|
||||
mu.Lock()
|
||||
failing = false
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 17))
|
||||
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
|
||||
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
|
||||
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 12, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
|
||||
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
|
||||
// minutes, and returns its id. It is not a Regular.
|
||||
func (p *planEnv) questLog(name string, minutes int) string {
|
||||
p.e.t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
p.ids[name] = d.ID
|
||||
p.crockPot(name, minutes)
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
|
||||
// for tonight wakes the scheduler, so its start reminder fires at start_by
|
||||
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
|
||||
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
|
||||
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
|
||||
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
|
||||
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.wakingPlans(e.pool())
|
||||
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 0))
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
|
||||
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 1))
|
||||
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package scheduler
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock and after the timer s fires by.
|
||||
func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) {
|
||||
s.now = now
|
||||
s.after = after
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"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/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's plan_draft schedule on the fake clock
|
||||
// against the real plan service, job runner, harness, draft_week and
|
||||
// notification outbox, with the AI faked.
|
||||
|
||||
// planEnv is a plan service over a scheduler env's store, the "process" a
|
||||
// scheduler runs it in. model answers draft_week.
|
||||
type planEnv struct {
|
||||
e *env
|
||||
svc *plan.Service
|
||||
model *llm.Fake
|
||||
ids map[string]string
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// poolNames are the seven Regulars pool stores, enough for a week.
|
||||
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
|
||||
|
||||
// pool stores poolNames as Regulars and returns their ids by name.
|
||||
func (e *env) pool() map[string]string {
|
||||
e.t.Helper()
|
||||
ids := map[string]string{}
|
||||
for _, name := range poolNames {
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids[name] = d.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
|
||||
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// noCalendar is a Calendar the schedule must never push to.
|
||||
type noCalendar struct{ t *testing.T }
|
||||
|
||||
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
|
||||
c.t.Error("plan_draft pushed to Calendar")
|
||||
return errors.New("no calendar")
|
||||
}
|
||||
|
||||
// plans builds a plan service drafting from the dinners ids over e with a
|
||||
// started job runner, which stop stops (and the test's end, if not before).
|
||||
// Its changes do not wake the scheduler: a draft job's wake would add a
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
// A test using it changes plans only from its own goroutine and waits for
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(p.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
p.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
// reply scripts the next draft_week answer: poolNames on the days of the
|
||||
// week starting weekStart, Monday to Sunday.
|
||||
func (p *planEnv) reply(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
var days []string
|
||||
for i, name := range poolNames {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
|
||||
}
|
||||
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
|
||||
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// draftJob is a draft_week jobs row.
|
||||
type draftJob struct {
|
||||
Status, Error string
|
||||
}
|
||||
|
||||
// drafts waits until no draft_week job is queued or running and returns
|
||||
// them all, oldest first.
|
||||
func (e *env) drafts() []draftJob {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var out []draftJob
|
||||
busy := false
|
||||
for rows.Next() {
|
||||
var j draftJob
|
||||
if err := rows.Scan(&j.Status, &j.Error); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
|
||||
out = append(out, j)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
rows.Close()
|
||||
if !busy {
|
||||
return out
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("draft jobs still under way: %+v", out)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantDrafts fails unless the draft_week jobs are got.
|
||||
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
|
||||
t.Helper()
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// weekPlans returns the week_start of every plan, oldest first.
|
||||
func (e *env) weekPlans() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var w string
|
||||
if err := rows.Scan(&w); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planReady returns the distinct plan_ready keys stored, oldest first.
|
||||
func (e *env) planReady() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
|
||||
w43 = mustLocalDate("2026-10-19")
|
||||
w44 = mustLocalDate("2026-10-26")
|
||||
)
|
||||
|
||||
func mustLocalDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
|
||||
// drafts the next ISO week through the plan service's draft job, period
|
||||
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 8, 59))
|
||||
wantDrafts(t, "before Friday 09:00", e.drafts())
|
||||
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
|
||||
t.Errorf("plan of W43 = %+v, want the drafted week", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 23, 9, 0))
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
|
||||
// here one the household drafted by hand, the period drafts nothing,
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
|
||||
// drafted, still within the catch-up window, drafts nothing more and sends
|
||||
// no second plan_ready: the period is already claimed.
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
|
||||
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
|
||||
// up and no one is notified; a restart within the catch-up window does not
|
||||
// draft the claimed period again.
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of draft_week: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
|
||||
}
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
// plan_draft_time change takes effect in the running scheduler; the old
|
||||
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantDrafts(t, "before Wednesday 18:30", e.drafts())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's rotation_check schedule on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
|
||||
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
|
||||
// Monday. The household's review time is the seeded 09:00, in Chicago.
|
||||
|
||||
// orzoDay is the Monday Lemon Orzo is planned on.
|
||||
var orzoDay = mustLocalDate("2026-10-12")
|
||||
|
||||
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
|
||||
// place of Beef Chili, and returns its id.
|
||||
func (p *planEnv) orzoWeek() string {
|
||||
p.e.t.Helper()
|
||||
id := p.questLog("Lemon Orzo", 30)
|
||||
p.planWeek(w42)
|
||||
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// rotationReviews returns the keys of the rotation_review notifications
|
||||
// stored, oldest first. The seeded preference sends rotation_review to push
|
||||
// only, so there is one row per event.
|
||||
func (e *env) rotationReviews() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("rotation_review row %+v, want push only", r)
|
||||
}
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// status returns the status of the dinner name.
|
||||
func (p *planEnv) status(name string) string {
|
||||
p.e.t.Helper()
|
||||
var s string
|
||||
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pendingReviews returns the plan dates of the pending Rotation checks of
|
||||
// the dinner name.
|
||||
func (p *planEnv) pendingReviews(name string) []string {
|
||||
p.e.t.Helper()
|
||||
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
|
||||
// Rotation check for orzoDay and exactly one rotation_review asking about
|
||||
// it, which opens Home.
|
||||
func (p *planEnv) wantOrzoTried(id string) {
|
||||
p.e.t.Helper()
|
||||
if got := p.status("Lemon Orzo"); got != "tried" {
|
||||
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
|
||||
}
|
||||
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
|
||||
key := "rotation_review:" + id + ":" + orzoDay.String()
|
||||
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
|
||||
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
|
||||
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
|
||||
if got != want {
|
||||
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
|
||||
// after a Quest-log dinner was planned, the schedule, period
|
||||
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
|
||||
// emits one rotation_review. It never makes the dinner a Regular.
|
||||
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 8, 59))
|
||||
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
|
||||
// Days later, still tried: only the household's keep makes a Regular.
|
||||
r.runUntil(at(2026, 10, 16, 12, 0))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
|
||||
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
|
||||
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
|
||||
})
|
||||
}
|
||||
|
||||
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
|
||||
// nothing tried, opens no Rotation check and emits nothing.
|
||||
func TestRotationCheckLeavesRegulars(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
|
||||
if got := p.status("Beef Chili"); got != "regular" {
|
||||
t.Errorf("Beef Chili status = %s, want regular", got)
|
||||
}
|
||||
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
|
||||
// period claimed, and MarkTried run again for the same day finds the
|
||||
// Rotation check open, so neither opens a second check or notification.
|
||||
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
|
||||
r2.runUntil(at(2026, 10, 13, 23, 59))
|
||||
p2.wantOrzoTried(id)
|
||||
|
||||
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
|
||||
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
|
||||
}
|
||||
p2.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
|
||||
// the scheduler runs that day's Rotation check once at boot.
|
||||
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
|
||||
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
|
||||
// Rotation check does not run at 09:00 and runs at 10:30.
|
||||
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 13, 10, 29))
|
||||
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 10, 30))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
// Package scheduler runs the household's timed work: each registered
|
||||
// schedule fires once per period at a household-local time read from the
|
||||
// household settings.
|
||||
//
|
||||
// # Periods and the claim
|
||||
//
|
||||
// Every fire time belongs to one period, named by a period key derived in
|
||||
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
|
||||
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
|
||||
// the fire date, or of the week after it, for a weekly rule. Before running,
|
||||
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
|
||||
// the claim, and a period already claimed, by an earlier run, an earlier
|
||||
// process or another instance, is skipped. A period therefore runs at most
|
||||
// once: a Run that fails is logged and its period is not retried.
|
||||
//
|
||||
// # Catch-up
|
||||
//
|
||||
// When the scheduler starts, and whenever it wakes late, it handles, oldest
|
||||
// first, every fire time of each schedule that passed within CatchUpWindow,
|
||||
// and the most recent fire time even when it passed longer ago. Each
|
||||
// unclaimed period within CatchUpWindow runs once. A most recent one that
|
||||
// passed longer ago is claimed without running and logged as missed, so it
|
||||
// can never run later, not after a restart and not after a settings change
|
||||
// moves the fire time. Other fire times older than CatchUpWindow are never
|
||||
// looked at.
|
||||
//
|
||||
// # Claim failures
|
||||
//
|
||||
// A claim that fails (the database refused the insert) is never skipped:
|
||||
// the scheduler logs it and keeps that period's fire time, and every
|
||||
// ClaimRetry claims that same period again until the claim succeeds. Only
|
||||
// then does it apply the catch-up rule to it, running it if its fire time
|
||||
// is within CatchUpWindow of the successful claim and recording it as
|
||||
// missed otherwise, and only then does it go on to later fire times, even
|
||||
// when one has passed meanwhile. Each period is claimed by its own fire
|
||||
// time, so the later period runs once as well.
|
||||
//
|
||||
// # Time zone and DST
|
||||
//
|
||||
// Fire times are wall-clock times in the household zone, never the process
|
||||
// zone, turned into instants by household.LocalTime. Its rule is the
|
||||
// scheduler's DST rule: a time that does not exist on a spring-forward day
|
||||
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
|
||||
// time that occurs twice on a fall-back day fires once, at its first
|
||||
// occurrence. Either way the period key is that local date's, so the
|
||||
// period still runs exactly once.
|
||||
//
|
||||
// # Fire times from data
|
||||
//
|
||||
// A DailyAt rule fires at a time read from the household's data rather
|
||||
// than the settings: its function gives, for each household-local date,
|
||||
// the instant the date's period fires, or none. The period is that date
|
||||
// even when the instant falls on another day. The scheduler asks again on
|
||||
// every pass, and whoever changes that data calls Wake once the change is
|
||||
// committed, so the next pass comes at once and a fire time the change
|
||||
// moved fires on time rather than up to maxWait late; the claim still runs
|
||||
// a period at most once. A lookup that fails is logged and repeated every
|
||||
// ClaimRetry, as a failed claim is.
|
||||
//
|
||||
// # Settings changes
|
||||
//
|
||||
// The scheduler subscribes to the household settings and records each change
|
||||
// with the instant it was saved. A change takes effect at once without a
|
||||
// restart: fire times up to that instant follow the old settings and later
|
||||
// ones the new, even when the scheduler gets to the change late. So moving
|
||||
// a fire time to earlier the same day does not fire the passed time, and a
|
||||
// new fire time that was still ahead when the change was saved fires.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
|
||||
const (
|
||||
SuggestionsWeekly = "suggestions_weekly"
|
||||
PlanDraft = "plan_draft"
|
||||
ShoppingChatAutosend = "shopping_chat_autosend"
|
||||
DayOfReminder = "day_of_reminder"
|
||||
RotationCheck = "rotation_check"
|
||||
WeekRollover = "week_rollover"
|
||||
AIRunsPurge = "ai_runs_purge"
|
||||
)
|
||||
|
||||
// names are the schedule names Register accepts.
|
||||
var names = map[string]bool{
|
||||
SuggestionsWeekly: true,
|
||||
PlanDraft: true,
|
||||
ShoppingChatAutosend: true,
|
||||
DayOfReminder: true,
|
||||
RotationCheck: true,
|
||||
WeekRollover: true,
|
||||
AIRunsPurge: true,
|
||||
}
|
||||
|
||||
// CatchUpWindow is how long after its fire time a period that has not run
|
||||
// still runs. A period found later than that is recorded as missed.
|
||||
const CatchUpWindow = 24 * time.Hour
|
||||
|
||||
// ClaimRetry is how long the scheduler waits before repeating a pass in
|
||||
// which a claim failed.
|
||||
const ClaimRetry = time.Minute
|
||||
|
||||
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
|
||||
// this often even if the host was suspended or its clock stepped.
|
||||
const maxWait = time.Hour
|
||||
|
||||
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
||||
// day, or on Weekday only when Weekly is set, or every day at the instant
|
||||
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
|
||||
type Rule struct {
|
||||
Weekly bool
|
||||
Weekday time.Weekday
|
||||
Time string
|
||||
// NextWeek keys a weekly period by the ISO week after the fire date,
|
||||
// for work done ahead for the coming week.
|
||||
NextWeek bool
|
||||
// At, instead of Time, gives the instant the period of a
|
||||
// household-local date fires.
|
||||
At FireAt
|
||||
}
|
||||
|
||||
// FireAt gives the instant the period of household-local date fires, or
|
||||
// false when that date's period does not fire.
|
||||
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
|
||||
|
||||
// Daily fires every day at hhmm; its period is the local date.
|
||||
func Daily(hhmm string) Rule {
|
||||
return Rule{Time: hhmm}
|
||||
}
|
||||
|
||||
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
|
||||
func Weekly(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm}
|
||||
}
|
||||
|
||||
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
|
||||
// the fire date's.
|
||||
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
||||
}
|
||||
|
||||
// DailyAt fires at most once a day, at the instant at gives for the date;
|
||||
// its period is that date, wherever the instant falls.
|
||||
func DailyAt(at FireAt) Rule {
|
||||
return Rule{At: at}
|
||||
}
|
||||
|
||||
// PeriodKey returns the period key of a fire on household-local date
|
||||
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
||||
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
||||
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
|
||||
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
|
||||
if !r.Weekly {
|
||||
return fireDate.String()
|
||||
}
|
||||
if r.NextWeek {
|
||||
return household.PeriodKey(addDays(fireDate, 7))
|
||||
}
|
||||
return household.PeriodKey(fireDate)
|
||||
}
|
||||
|
||||
// validate reports whether r can fire.
|
||||
func (r Rule) validate() error {
|
||||
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
|
||||
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
|
||||
}
|
||||
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
||||
return errors.New("a daily rule has no weekday or next week")
|
||||
}
|
||||
if r.At != nil {
|
||||
if r.Weekly || r.Time != "" {
|
||||
return errors.New("a DailyAt rule has no weekday or time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Any date checks the time's form.
|
||||
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
||||
return err
|
||||
}
|
||||
|
||||
// Period is one period of a schedule, as given to its Run.
|
||||
type Period struct {
|
||||
// Key is the period key claimed in schedule_runs.
|
||||
Key string
|
||||
// FireAt is the instant the period fired, in the household zone.
|
||||
FireAt time.Time
|
||||
// Date is the household-local date of FireAt.
|
||||
Date store.LocalDate
|
||||
// Week is the ISO Monday of the week Key names; for a daily rule, of
|
||||
// the week containing Date.
|
||||
Week store.LocalDate
|
||||
}
|
||||
|
||||
// Schedule is registered work that runs once per period.
|
||||
type Schedule struct {
|
||||
// Name is one of the schedule name constants.
|
||||
Name string
|
||||
// Key, when set, prefixes the schedule's period keys with Key and a
|
||||
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||
// different periods. Name and Key together are unique.
|
||||
Key string
|
||||
// PeriodKey, when set, gives the period key claimed for p in place of
|
||||
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
|
||||
// p.Key is the rule's key. It must give each period its own key. A
|
||||
// schedule sets Key or PeriodKey, not both.
|
||||
PeriodKey func(p Period) string
|
||||
// Rule returns when the schedule fires under settings. It is called
|
||||
// again after every settings change.
|
||||
Rule func(settings household.Settings) Rule
|
||||
// Run does the period's work. It runs on the scheduler's goroutine,
|
||||
// one schedule at a time, so it must return promptly: long work is
|
||||
// enqueued as a job. ctx is cancelled when the scheduler stops. An
|
||||
// error is logged; the period stays claimed and is not retried.
|
||||
Run func(ctx context.Context, p Period) error
|
||||
}
|
||||
|
||||
// label names sch in errors and logs: its Name, and its Key if set.
|
||||
func (sch Schedule) label() string {
|
||||
if sch.Key == "" {
|
||||
return sch.Name
|
||||
}
|
||||
return sch.Name + ":" + sch.Key
|
||||
}
|
||||
|
||||
// Settings is the household settings source: *household.SettingsService.
|
||||
type Settings interface {
|
||||
Get(ctx context.Context) (household.Settings, error)
|
||||
Subscribe(fn func(household.Settings)) (unsubscribe func())
|
||||
}
|
||||
|
||||
// Scheduler runs the registered schedules.
|
||||
type Scheduler struct {
|
||||
st *store.Store
|
||||
settings Settings
|
||||
logger *slog.Logger
|
||||
|
||||
// now is the clock and after the timer; tests replace both.
|
||||
now func() time.Time
|
||||
after func(d time.Duration) <-chan time.Time
|
||||
|
||||
// wake holds one token when the settings or the data a DailyAt rule
|
||||
// reads changed since the loop last looked; pending is the settings
|
||||
// changes to apply, oldest first.
|
||||
wake chan struct{}
|
||||
pendingMu sync.Mutex
|
||||
pending []change
|
||||
|
||||
mu sync.Mutex
|
||||
schedules []Schedule
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New returns a scheduler over st reading the household settings from
|
||||
// settings.
|
||||
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("scheduler: nil store")
|
||||
}
|
||||
if settings == nil {
|
||||
return nil, errors.New("scheduler: nil settings")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("scheduler: nil logger")
|
||||
}
|
||||
return &Scheduler{
|
||||
st: st,
|
||||
settings: settings,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
after: time.After,
|
||||
wake: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register adds sch. It must be called before Start, once per name.
|
||||
func (s *Scheduler) Register(sch Schedule) error {
|
||||
if !names[sch.Name] {
|
||||
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
||||
}
|
||||
if sch.Rule == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
|
||||
}
|
||||
if sch.Run == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
|
||||
}
|
||||
if strings.Contains(sch.Key, ":") {
|
||||
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||
}
|
||||
if sch.Key != "" && sch.PeriodKey != nil {
|
||||
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("scheduler: register %s: already started", sch.label())
|
||||
}
|
||||
for _, have := range s.schedules {
|
||||
if have.Name == sch.Name && have.Key == sch.Key {
|
||||
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
|
||||
}
|
||||
}
|
||||
s.schedules = append(s.schedules, sch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start reads the settings and starts the scheduler, which first catches
|
||||
// up on each schedule's recent periods and then fires schedules as
|
||||
// their times come. Settings whose zone or rules are invalid are an error
|
||||
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
|
||||
// for it.
|
||||
func (s *Scheduler) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return errors.New("scheduler: already started")
|
||||
}
|
||||
// Subscribing first means a change saved after the read below still
|
||||
// wakes the loop.
|
||||
unsubscribe := s.settings.Subscribe(s.changed)
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return fmt.Errorf("scheduler: %w", err)
|
||||
}
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return err
|
||||
}
|
||||
s.started = true
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer unsubscribe()
|
||||
s.loop(ctx, p)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the scheduler has stopped.
|
||||
func (s *Scheduler) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// change is a settings change and the instant it was saved.
|
||||
type change struct {
|
||||
at time.Time
|
||||
settings household.Settings
|
||||
}
|
||||
|
||||
// changed is the settings subscriber. It runs on the updating goroutine, so
|
||||
// it only records the change and wakes the loop.
|
||||
func (s *Scheduler) changed(settings household.Settings) {
|
||||
s.pendingMu.Lock()
|
||||
s.pending = append(s.pending, change{at: s.now(), settings: settings})
|
||||
s.pendingMu.Unlock()
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Wake makes the scheduler look up its fire times again now rather than at
|
||||
// its next timer. Call it after committing a change to the data a DailyAt
|
||||
// rule reads, such as tonight's planned dinner. It never blocks.
|
||||
func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default: // the loop already has a token and looks again then
|
||||
}
|
||||
}
|
||||
|
||||
// takeChanges returns and clears the pending changes.
|
||||
func (s *Scheduler) takeChanges() []change {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
out := s.pending
|
||||
s.pending = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// plan is the schedules compiled against one version of the settings.
|
||||
type plan struct {
|
||||
loc *time.Location
|
||||
items []item
|
||||
}
|
||||
|
||||
type item struct {
|
||||
sch Schedule
|
||||
rule Rule
|
||||
}
|
||||
|
||||
// compile builds the plan for settings.
|
||||
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
||||
loc, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scheduler: household zone: %w", err)
|
||||
}
|
||||
p := &plan{loc: loc, items: make([]item, len(schedules))}
|
||||
for i, sch := range schedules {
|
||||
rule := sch.Rule(settings)
|
||||
if err := rule.validate(); err != nil {
|
||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
|
||||
}
|
||||
p.items[i] = item{sch: sch, rule: rule}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// loop fires schedules until ctx is cancelled. done is the instant up to
|
||||
// which fire times have been handled; it starts at the zero time so the
|
||||
// first pass catches up. retry is the periods whose claim failed, each
|
||||
// with its own fire time. Each pass first claims retry again; while any
|
||||
// claim there still fails, the pass stops and is repeated after
|
||||
// ClaimRetry. Otherwise it applies the pending settings changes in order,
|
||||
// handling fire times up to a change under the plan it replaces, then
|
||||
// handles fire times up to now under the current plan, stopping after the
|
||||
// first stretch in which a claim failed.
|
||||
func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
||||
var done time.Time
|
||||
var pending []change
|
||||
var retry []target
|
||||
for {
|
||||
now := s.now()
|
||||
pending = append(pending, s.takeChanges()...)
|
||||
retry = s.retry(ctx, retry, now)
|
||||
for len(retry) == 0 && len(pending) > 0 {
|
||||
c := pending[0]
|
||||
if c.at.After(done) {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, c.at, now)
|
||||
}
|
||||
done = c.at
|
||||
}
|
||||
p = s.compileChange(ctx, c.settings)
|
||||
pending = pending[1:]
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, now, now)
|
||||
}
|
||||
done = now
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var timeout <-chan time.Time
|
||||
switch {
|
||||
case len(retry) > 0:
|
||||
timeout = s.after(ClaimRetry)
|
||||
case p != nil:
|
||||
next, ok, err := p.next(ctx, now)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
timeout = s.after(ClaimRetry)
|
||||
case ok:
|
||||
timeout = s.after(min(next.Sub(now), maxWait))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timeout:
|
||||
case <-s.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compileChange returns the plan for changed settings, or nil when they
|
||||
// are unusable.
|
||||
func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan {
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
// Settings are validated before they are saved, so this is a
|
||||
// bug; firing at a guessed time would be worse than not firing
|
||||
// until the settings are fixed.
|
||||
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// target is one schedule's periods still to handle, oldest first, each
|
||||
// named by its fire time and the household-local date of its period. A
|
||||
// target whose fire times could not be looked up has lookup set instead,
|
||||
// with the stretch (done, until] to look them up in again.
|
||||
type target struct {
|
||||
it item
|
||||
loc *time.Location
|
||||
fires []fireTime
|
||||
|
||||
lookup bool
|
||||
done, until time.Time
|
||||
}
|
||||
|
||||
// fire handles, for every schedule, its catch-up fire times after done and
|
||||
// at or before until; now is the current instant. It returns the
|
||||
// targets whose lookup or claim failed, to be handled again.
|
||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||
var failed []target
|
||||
for _, it := range p.items {
|
||||
if ctx.Err() != nil {
|
||||
return failed
|
||||
}
|
||||
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// fireItem handles, oldest first, it's fire times after done and at or
|
||||
// before until that passed within CatchUpWindow of now, and the most recent
|
||||
// of them even when it passed longer ago. It reports false, with the target
|
||||
// to handle again, when the fire times could not be looked up or a period's
|
||||
// claim failed.
|
||||
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||
fs, err := fireBetween(ctx, it.rule, loc, done, until)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||
}
|
||||
i := 0
|
||||
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
|
||||
i++
|
||||
}
|
||||
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
|
||||
}
|
||||
|
||||
// handleAll handles the periods of t in order, stopping at the first whose
|
||||
// claim failed. It reports false, with that period and those after it,
|
||||
// when one did.
|
||||
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
|
||||
for i, f := range t.fires {
|
||||
if !s.handle(ctx, t.it, f, now) {
|
||||
t.fires = t.fires[i:]
|
||||
return t, false
|
||||
}
|
||||
}
|
||||
return target{}, true
|
||||
}
|
||||
|
||||
// retry handles targets again at now and returns those whose lookup or
|
||||
// claim still failed.
|
||||
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
||||
var failed []target
|
||||
for i, t := range targets {
|
||||
if ctx.Err() != nil {
|
||||
return append(failed, targets[i:]...)
|
||||
}
|
||||
if t.lookup {
|
||||
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if again, ok := s.handleAll(ctx, t, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// handle claims the period of it's fire time f and runs it, or records it
|
||||
// as missed when f is more than CatchUpWindow before now. It reports false
|
||||
// when the claim failed and the period is still unclaimed.
|
||||
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
|
||||
at, date := f.at, f.date
|
||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||
if it.rule.NextWeek {
|
||||
period.Week = household.WeekStart(addDays(date, 7))
|
||||
} else {
|
||||
period.Week = household.WeekStart(date)
|
||||
}
|
||||
switch {
|
||||
case it.sch.Key != "":
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
case it.sch.PeriodKey != nil:
|
||||
period.Key = it.sch.PeriodKey(period)
|
||||
}
|
||||
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
|
||||
|
||||
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
return false
|
||||
}
|
||||
if !claimed {
|
||||
return true
|
||||
}
|
||||
if late := now.Sub(at); late > CatchUpWindow {
|
||||
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
|
||||
return true
|
||||
}
|
||||
if err := it.sch.Run(ctx, period); err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
|
||||
return true
|
||||
}
|
||||
log.InfoContext(ctx, "scheduler: ran")
|
||||
return true
|
||||
}
|
||||
|
||||
// claim inserts schedule_runs(name, key) and reports whether this call
|
||||
// inserted it.
|
||||
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
|
||||
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
|
||||
ID: store.NewID(),
|
||||
Schedule: name,
|
||||
PeriodKey: key,
|
||||
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// next returns the earliest fire time of any schedule after now, or false
|
||||
// when there is none.
|
||||
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
|
||||
var next time.Time
|
||||
for _, it := range p.items {
|
||||
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
|
||||
}
|
||||
if ok && (next.IsZero() || f.at.Before(next)) {
|
||||
next = f.at
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero(), nil
|
||||
}
|
||||
|
||||
// window is how many days either side of a date fireBetween and fireAfter
|
||||
// search: more than a week, so a weekly rule always has a fire time on
|
||||
// each side.
|
||||
const window = 8
|
||||
|
||||
// atWindow is how many days either side of a date fireBetween and fireAfter
|
||||
// look up a DailyAt rule's fire times. A period firing further off is
|
||||
// looked up on a later pass, which comes within maxWait.
|
||||
const atWindow = 2
|
||||
|
||||
// fireTime is a fire time and the household-local date of its period.
|
||||
type fireTime struct {
|
||||
at time.Time
|
||||
date store.LocalDate
|
||||
}
|
||||
|
||||
// fireBetween returns the fire times of r in zone loc after from and at or
|
||||
// before t, oldest first.
|
||||
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(from) && !f.at.After(t) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return fireTime{}, false, err
|
||||
}
|
||||
var best fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, !best.at.IsZero(), nil
|
||||
}
|
||||
|
||||
// fireTimes returns r's fire times for the local dates within window days
|
||||
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
|
||||
// resolve was validated at compile, so it cannot; a DailyAt lookup that
|
||||
// fails is an error.
|
||||
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
|
||||
today := household.Today(t, loc)
|
||||
span := window
|
||||
if r.At != nil {
|
||||
span = atWindow
|
||||
}
|
||||
var out []fireTime
|
||||
for i := -span; i <= span; i++ {
|
||||
d := addDays(today, i)
|
||||
if r.Weekly && weekday(d) != r.Weekday {
|
||||
continue
|
||||
}
|
||||
if r.At != nil {
|
||||
at, ok, err := r.At(ctx, d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fire time of %s: %w", d, err)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
continue
|
||||
}
|
||||
at, err := household.LocalTime(loc, d, r.Time)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
||||
}
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addDays returns d plus n calendar days.
|
||||
func addDays(d store.LocalDate, n int) store.LocalDate {
|
||||
y, m, day := d.Date()
|
||||
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// weekday returns the day of the week of d.
|
||||
func weekday(d store.LocalDate) time.Weekday {
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const zone = "America/Chicago"
|
||||
|
||||
// chicago is the household zone of every test.
|
||||
var chicago = mustZone(zone)
|
||||
|
||||
func mustZone(name string) *time.Location {
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// at returns the instant clocks in Chicago read the given time.
|
||||
func at(year int, month time.Month, day, hour, minute int) time.Time {
|
||||
return time.Date(year, month, day, hour, minute, 0, 0, chicago)
|
||||
}
|
||||
|
||||
// fakeClock is a clock whose time moves only when the test moves it.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
timers []fakeTimer
|
||||
// waits receives one value per After call, so the test knows the
|
||||
// scheduler finished a pass and is waiting.
|
||||
waits chan struct{}
|
||||
}
|
||||
|
||||
type fakeTimer struct {
|
||||
deadline time.Time
|
||||
ch chan time.Time
|
||||
}
|
||||
|
||||
func newFakeClock(now time.Time) *fakeClock {
|
||||
return &fakeClock{now: now, waits: make(chan struct{}, 100)}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
// After returns a timer that fires d from now. The scheduler waits on only
|
||||
// its latest timer, so this one replaces any still pending: a timer the
|
||||
// scheduler gave up on when a wake came first never fires.
|
||||
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
|
||||
c.mu.Lock()
|
||||
ch := make(chan time.Time, 1)
|
||||
c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}}
|
||||
c.mu.Unlock()
|
||||
c.waits <- struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
// set moves the clock to t and fires the timers due by then, reporting
|
||||
// whether any fired.
|
||||
func (c *fakeClock) set(t time.Time) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = t
|
||||
fired := false
|
||||
kept := c.timers[:0]
|
||||
for _, tm := range c.timers {
|
||||
if tm.deadline.After(t) {
|
||||
kept = append(kept, tm)
|
||||
continue
|
||||
}
|
||||
tm.ch <- t
|
||||
fired = true
|
||||
}
|
||||
c.timers = kept
|
||||
return fired
|
||||
}
|
||||
|
||||
// nextDeadline returns the earliest pending timer deadline.
|
||||
func (c *fakeClock) nextDeadline() (time.Time, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var next time.Time
|
||||
for _, tm := range c.timers {
|
||||
if next.IsZero() || tm.deadline.Before(next) {
|
||||
next = tm.deadline
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero()
|
||||
}
|
||||
|
||||
// recorder records the periods a schedule ran.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
runs []scheduler.Period
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recorder) run(_ context.Context, p scheduler.Period) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runs = append(r.runs, p)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recorder) periods() []scheduler.Period {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return slices.Clone(r.runs)
|
||||
}
|
||||
|
||||
func (r *recorder) keys() []string {
|
||||
var keys []string
|
||||
for _, p := range r.periods() {
|
||||
keys = append(keys, p.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// env is a migrated database with household settings in Chicago.
|
||||
type env struct {
|
||||
t *testing.T
|
||||
st *store.Store
|
||||
settings *household.SettingsService
|
||||
// sched is the scheduler start last started, which wake wakes.
|
||||
sched atomic.Pointer[scheduler.Scheduler]
|
||||
}
|
||||
|
||||
// wake wakes the scheduler start last started, as the services' wake does
|
||||
// in the server; before the first start it does nothing.
|
||||
func (e *env) wake() {
|
||||
if s := e.sched.Load(); s != nil {
|
||||
s.Wake()
|
||||
}
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{t: t, st: st, settings: household.NewSettingsService(st)}
|
||||
}
|
||||
|
||||
// update changes the household settings.
|
||||
func (e *env) update(patch household.SettingsPatch) {
|
||||
e.t.Helper()
|
||||
if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// claimed returns the period keys schedule_runs holds for name.
|
||||
func (e *env) claimed(name string) []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// running is a started scheduler on a fake clock.
|
||||
type running struct {
|
||||
t *testing.T
|
||||
clock *fakeClock
|
||||
stop func()
|
||||
}
|
||||
|
||||
// start starts a scheduler on e at now with the schedules, and stops it
|
||||
// when the test ends. It returns once the first pass, the catch-up, is done.
|
||||
func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running {
|
||||
e.t.Helper()
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(now)
|
||||
scheduler.SetClock(s, clock.Now, clock.After)
|
||||
for _, sch := range schedules {
|
||||
if err := s.Register(sch); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := s.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
e.sched.Store(s)
|
||||
var once sync.Once
|
||||
stop := func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
s.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(stop)
|
||||
r := &running{t: e.t, clock: clock, stop: stop}
|
||||
r.idle()
|
||||
return r
|
||||
}
|
||||
|
||||
// idle waits until the scheduler finished a pass and is waiting.
|
||||
func (r *running) idle() {
|
||||
r.t.Helper()
|
||||
select {
|
||||
case <-r.clock.waits:
|
||||
case <-time.After(10 * time.Second):
|
||||
r.t.Fatal("scheduler did not finish its pass")
|
||||
}
|
||||
}
|
||||
|
||||
// runUntil moves the clock to to, stopping at every instant the scheduler
|
||||
// asked to wake at on the way, as a real clock would.
|
||||
func (r *running) runUntil(to time.Time) {
|
||||
r.t.Helper()
|
||||
for {
|
||||
next, ok := r.clock.nextDeadline()
|
||||
if !ok || next.After(to) {
|
||||
if r.clock.set(to) {
|
||||
r.idle()
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.clock.set(next) {
|
||||
r.idle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// changed waits for the scheduler to apply a settings change, or to look
|
||||
// again after a wake.
|
||||
func (r *running) changed() {
|
||||
r.t.Helper()
|
||||
r.idle()
|
||||
}
|
||||
|
||||
func reminder(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func suggestions(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(s household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime)
|
||||
},
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func planDraft(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.PlanDraft,
|
||||
Rule: func(s household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime)
|
||||
},
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func wantKeys(t *testing.T, what string, got, want []string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("%s = %q, want %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiresOncePerPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Seeded reminder time is 16:30; boot just after it on Oct 6 runs it.
|
||||
r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec))
|
||||
wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
r.runUntil(at(2026, 10, 7, 16, 29))
|
||||
wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"})
|
||||
r.runUntil(at(2026, 10, 9, 23, 59))
|
||||
wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"})
|
||||
|
||||
for i, p := range rec.periods()[1:] {
|
||||
want := at(2026, 10, 7+i, 16, 30)
|
||||
if !p.FireAt.Equal(want) {
|
||||
t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want)
|
||||
}
|
||||
if p.FireAt.Location().String() != zone {
|
||||
t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone)
|
||||
}
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
|
||||
func TestRestartMidPeriodDoesNotRerun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var first recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday
|
||||
// The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run.
|
||||
wantKeys(t, "first boot", first.keys(), nil)
|
||||
r.runUntil(at(2026, 10, 11, 9, 0))
|
||||
wantKeys(t, "first process", first.keys(), []string{"2026-W41"})
|
||||
r.stop()
|
||||
|
||||
// The restart is an hour after the Sunday 08:00 fire, mid-period.
|
||||
var second recorder
|
||||
r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second))
|
||||
wantKeys(t, "after restart", second.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantKeys(t, "rest of the period", second.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 8, 0))
|
||||
wantKeys(t, "next period", second.keys(), []string{"2026-W42"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
|
||||
}
|
||||
|
||||
func TestCatchUpWithin24Hours(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Sunday Oct 11 08:00 passed 23h59m before boot.
|
||||
e.start(at(2026, 10, 12, 7, 59), suggestions(&rec))
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"})
|
||||
if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) {
|
||||
t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCatchUpBeyond24Hours(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Sunday Oct 11 08:00 passed 24h01m before boot.
|
||||
r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec))
|
||||
wantKeys(t, "boot", rec.keys(), nil)
|
||||
// The missed period is recorded, so it never runs later.
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"})
|
||||
|
||||
// Moving the fire time to Sunday 23:00 puts that week's fire time
|
||||
// within 24 h of now. The change itself never fires a passed time...
|
||||
e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")})
|
||||
r.changed()
|
||||
wantKeys(t, "after a settings change", rec.keys(), nil)
|
||||
r.stop()
|
||||
|
||||
// ...and the restart's catch-up finds the period already recorded.
|
||||
var again recorder
|
||||
r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 23, 0))
|
||||
wantKeys(t, "next period", again.keys(), []string{"2026-W42"})
|
||||
}
|
||||
|
||||
func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Days of downtime: only Oct 9 16:30, 30 minutes ago, runs.
|
||||
e.start(at(2026, 10, 9, 17, 0), reminder(&rec))
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
|
||||
}
|
||||
|
||||
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
|
||||
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
|
||||
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
|
||||
var rec recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
|
||||
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
|
||||
// ago, is not looked at.
|
||||
r := e.start(at(2026, 3, 9, 1, 35), review)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
|
||||
wantFired(t, rec.periods(),
|
||||
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
|
||||
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
|
||||
)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
|
||||
r.stop()
|
||||
|
||||
// A restart within the window finds both periods claimed.
|
||||
var again recorder
|
||||
review.Run = again.run
|
||||
r2 := e.start(at(2026, 3, 9, 1, 40), review)
|
||||
r2.runUntil(at(2026, 3, 9, 23, 0))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
}
|
||||
|
||||
func TestSpringForward(t *testing.T) {
|
||||
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")})
|
||||
var daily, skipped recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: skipped.run,
|
||||
}
|
||||
r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review)
|
||||
r.runUntil(at(2026, 3, 9, 12, 0))
|
||||
|
||||
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
|
||||
wantFired(t, daily.periods(),
|
||||
time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST
|
||||
time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT
|
||||
time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC),
|
||||
)
|
||||
|
||||
// 02:30 does not exist on Mar 8: it fires once, an hour later at
|
||||
// 03:30 CDT, keyed by Mar 8.
|
||||
wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
|
||||
wantFired(t, skipped.periods(),
|
||||
time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST
|
||||
time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT
|
||||
time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT
|
||||
)
|
||||
if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" {
|
||||
t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallBack(t *testing.T) {
|
||||
// Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")})
|
||||
var daily, repeated recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: repeated.run,
|
||||
}
|
||||
r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review)
|
||||
r.runUntil(at(2026, 11, 2, 12, 0))
|
||||
|
||||
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
|
||||
wantFired(t, daily.periods(),
|
||||
time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT
|
||||
time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST
|
||||
time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC),
|
||||
)
|
||||
|
||||
// 01:30 happens twice on Nov 1: it fires once, at the first (CDT).
|
||||
wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
|
||||
wantFired(t, repeated.periods(),
|
||||
time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT
|
||||
time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT
|
||||
time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST
|
||||
)
|
||||
}
|
||||
|
||||
func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
|
||||
var rec recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
// Boot between the two 01:30s of Nov 1: the first ran in an earlier
|
||||
// process, so the second is not a new period.
|
||||
first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)
|
||||
r := e.start(first.Add(-time.Minute), review)
|
||||
r.runUntil(first)
|
||||
wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"})
|
||||
r.stop()
|
||||
|
||||
var again recorder
|
||||
review.Run = again.run
|
||||
r2 := e.start(first.Add(30*time.Minute), review)
|
||||
r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST
|
||||
wantKeys(t, "second 01:30", again.keys(), nil)
|
||||
}
|
||||
|
||||
func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%d runs, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].FireAt.Equal(want[i]) {
|
||||
t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOWeek53Boundary(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var sugg, draft recorder
|
||||
// Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the
|
||||
// plan draft Friday 09:00 keys the week after.
|
||||
r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft))
|
||||
r.runUntil(at(2027, 1, 11, 0, 0))
|
||||
|
||||
wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"})
|
||||
// Sunday 2027-01-03 is the last day of 2026-W53.
|
||||
if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" {
|
||||
t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week)
|
||||
}
|
||||
// Friday Dec 18, two days before boot, was missed.
|
||||
wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"})
|
||||
// Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01.
|
||||
if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" {
|
||||
t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week)
|
||||
}
|
||||
if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" {
|
||||
t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeriodKey(t *testing.T) {
|
||||
date := func(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
tests := []struct {
|
||||
rule scheduler.Rule
|
||||
date string
|
||||
want string
|
||||
}{
|
||||
{scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"},
|
||||
{scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"},
|
||||
{scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"},
|
||||
{scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"},
|
||||
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"},
|
||||
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want {
|
||||
t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsChangeMovesNextFire(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
// Earlier but still ahead: fires at the new time, not the old.
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("13:00")})
|
||||
r.changed()
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) {
|
||||
t.Errorf("next wake %s, want the new 13:00", next)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 7, 13, 0))
|
||||
wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
|
||||
// Moved to a time already passed today: nothing fires now, and
|
||||
// tomorrow fires at the new time.
|
||||
r.runUntil(at(2026, 10, 8, 12, 0))
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("11:00")})
|
||||
r.changed()
|
||||
wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 9, 10, 59))
|
||||
wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 9, 11, 0))
|
||||
wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"})
|
||||
}
|
||||
|
||||
// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it
|
||||
// even when the scheduler only gets to the change at 12:02, after the new
|
||||
// fire time has passed.
|
||||
func TestSettingsChangeAppliedLateStillFires(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while
|
||||
// the settings change and the clock moves on.
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
blocker := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") },
|
||||
Run: func(_ context.Context, p scheduler.Period) error {
|
||||
if p.Key == "2026-10-07" {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker)
|
||||
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
r.clock.set(at(2026, 10, 7, 12, 0))
|
||||
<-started
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("12:01")})
|
||||
r.clock.set(at(2026, 10, 7, 12, 2))
|
||||
close(release)
|
||||
r.idle() // the pass the blocker held
|
||||
r.idle() // the pass that applies the change
|
||||
wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
|
||||
}
|
||||
|
||||
// failClaims makes every claim fail until recover is called.
|
||||
func (e *env) failClaims() (recover func()) {
|
||||
e.t.Helper()
|
||||
for _, q := range []string{
|
||||
`CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`,
|
||||
`INSERT INTO fail_claims VALUES (1)`,
|
||||
`CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs
|
||||
WHEN (SELECT on_ FROM fail_claims) = 1
|
||||
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(q); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
e.t.Helper()
|
||||
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A claim that fails is retried, not skipped, and the period then runs once.
|
||||
func TestClaimFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
recoverClaims := e.failClaims()
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
wantKeys(t, "boot with failing claims", rec.keys(), nil)
|
||||
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) {
|
||||
t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry)
|
||||
}
|
||||
|
||||
recoverClaims()
|
||||
r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry))
|
||||
wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"})
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
|
||||
}
|
||||
|
||||
// A claim that keeps failing past the next daily fire is retried for its
|
||||
// own period, not the most recent one: that period, now more than
|
||||
// CatchUpWindow old, is recorded as missed, and the later period runs once.
|
||||
func TestClaimFailureRecoversAfterNextFire(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
recoverClaims := e.failClaims()
|
||||
var rec recorder
|
||||
// The 7 October 16:30 reminder is due and its claim fails, past the
|
||||
// 8 October 16:30 fire.
|
||||
r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec))
|
||||
r.runUntil(at(2026, 10, 8, 17, 0))
|
||||
wantKeys(t, "runs while failing", rec.keys(), nil)
|
||||
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
|
||||
|
||||
// Claims recover 24h31m after the 7 October fire.
|
||||
recoverClaims()
|
||||
r.runUntil(at(2026, 10, 8, 17, 1))
|
||||
wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"})
|
||||
wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"})
|
||||
|
||||
r.runUntil(at(2026, 10, 9, 16, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"})
|
||||
}
|
||||
|
||||
func TestSettingsChangeTimezone(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
e.update(household.SettingsPatch{Timezone: ptr("America/New_York")})
|
||||
r.changed()
|
||||
// 16:30 in New York is 15:30 in Chicago.
|
||||
r.runUntil(at(2026, 10, 7, 15, 30))
|
||||
wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" {
|
||||
t.Errorf("fired in zone %s, want America/New_York", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunErrorKeepsPeriodClaimed(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
rec := recorder{err: errors.New("boom")}
|
||||
r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec))
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.stop()
|
||||
var again recorder
|
||||
e.start(at(2026, 10, 7, 23, 0), reminder(&again))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
bad := []scheduler.Schedule{
|
||||
{Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule},
|
||||
{Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
}
|
||||
for _, sch := range bad {
|
||||
if err := s.Register(sch); err == nil {
|
||||
t.Errorf("Register(%q) accepted an invalid schedule", sch.Name)
|
||||
}
|
||||
}
|
||||
if err := s.Register(reminder(&rec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Register(reminder(&rec)); err == nil {
|
||||
t.Error("Register accepted a second schedule with the same name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRejectsInvalidRule(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
if err := s.Register(scheduler.Schedule{
|
||||
Name: scheduler.WeekRollover,
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") },
|
||||
Run: rec.run,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Start(context.Background()); err == nil {
|
||||
s.Wait()
|
||||
t.Fatal("Start accepted weekday 7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopEndsTheLoop(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("scheduler did not stop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// The tests in this file run suggest's suggestions_weekly schedule on the
|
||||
// fake clock against the real suggestion service, job runner, harness and
|
||||
// notification outbox, with the AI and recipe sources faked.
|
||||
|
||||
// suggestEnv is a suggestion service over a scheduler env's store, the
|
||||
// "process" a scheduler runs it in. side answers suggest_side_quests and
|
||||
// model normalize_recipe while grounding.
|
||||
type suggestEnv struct {
|
||||
e *env
|
||||
svc *suggest.Service
|
||||
side *llm.Fake
|
||||
model *llm.Fake
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// syncBuffer collects log output written by concurrent goroutines.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
|
||||
// has Regulars to suggest from.
|
||||
func (e *env) regulars() {
|
||||
e.t.Helper()
|
||||
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// suggestions builds a suggestion service over e with a started job
|
||||
// runner, which stop stops (and the test's end, if not before).
|
||||
func (e *env) suggestions() *suggestEnv {
|
||||
e.t.Helper()
|
||||
s := &suggestEnv{
|
||||
e: e,
|
||||
side: &llm.Fake{},
|
||||
model: &llm.Fake{},
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
logs: &syncBuffer{},
|
||||
}
|
||||
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
s.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(s.stop)
|
||||
return s
|
||||
}
|
||||
|
||||
// week scripts the next batch: suggest_side_quests answers with the
|
||||
// Side quests named names, each grounded by its own recipe.
|
||||
func (s *suggestEnv) week(names ...string) {
|
||||
s.e.t.Helper()
|
||||
var quests []string
|
||||
for _, name := range names {
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
s.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
|
||||
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
|
||||
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
|
||||
}
|
||||
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
|
||||
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// batch is a suggestion_batches row.
|
||||
type batch struct {
|
||||
ID, Trigger, Status string
|
||||
}
|
||||
|
||||
// batches returns every suggestion batch, oldest first.
|
||||
func (e *env) batches() []batch {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []batch
|
||||
for rows.Next() {
|
||||
var b batch
|
||||
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settled waits until no batch is pending or running and returns the
|
||||
// batches.
|
||||
func (e *env) settled() []batch {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
bs := e.batches()
|
||||
busy := false
|
||||
for _, b := range bs {
|
||||
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
|
||||
}
|
||||
if !busy {
|
||||
return bs
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("batches still under way: %+v", bs)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBatches fails unless the batches are scheduled with statuses want.
|
||||
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
|
||||
t.Helper()
|
||||
ok := len(got) == len(want)
|
||||
for i := 0; ok && i < len(got); i++ {
|
||||
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// notified returns the new_suggestions keys stored, oldest first, and
|
||||
// reports an error for any whose title is not the one titles gives it.
|
||||
func (e *env) notified(titles ...string) []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
|
||||
for i, r := range rows {
|
||||
keys = append(keys, r.Key)
|
||||
if i < len(titles) && r.Payload.Title != titles[i] {
|
||||
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
|
||||
// Sunday 08:00 the schedule starts one scheduled batch a week, period
|
||||
// suggestions_weekly:YYYY-Www, and each tells the household once, with
|
||||
// new_suggestions:YYYY-Www counting its new Side quests.
|
||||
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
|
||||
|
||||
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 7, 59))
|
||||
wantBatches(t, "before Sunday 08:00", e.settled())
|
||||
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
|
||||
|
||||
r.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
|
||||
r.runUntil(at(2026, 10, 18, 9, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
|
||||
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
|
||||
// household's suggestions_dow and suggestions_time.
|
||||
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantBatches(t, "before Wednesday 18:30", e.settled())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
|
||||
// fails the batch fails with the failure logged and recorded in ai_runs,
|
||||
// and the household is not notified.
|
||||
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
|
||||
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
|
||||
// W40 is the boot's missed period, recorded without running.
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
|
||||
// batch ran, still within the catch-up window, starts no second batch and
|
||||
// sends no second notification: the period is already claimed.
|
||||
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
|
||||
r.stop()
|
||||
s.stop()
|
||||
|
||||
// The restarted process has a new job runner, suggestion service and
|
||||
// scheduler; it would answer a second batch if one were started.
|
||||
s2 := e.suggestions()
|
||||
s2.week("Lamb Kofta")
|
||||
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
|
||||
r2.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
|
||||
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
|
||||
// the next Sunday's batch notifies for W42.
|
||||
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
s.week("Lamb Kofta")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
|
||||
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
r.runUntil(at(2026, 10, 18, 8, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
|
||||
// fires while a batch is pending starts no second one and says so.
|
||||
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.stop() // the manual batch stays pending
|
||||
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
|
||||
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run shopping's shopping_chat_autosend and
|
||||
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
|
||||
// against the real shopping service, Google Chat client (posting to a fake
|
||||
// webhook), notification preferences and ai_runs purge. The seeded
|
||||
// autosend is Saturday 08:00 in Chicago.
|
||||
|
||||
// w41 is the Monday of 2026-W41.
|
||||
var w41 = mustLocalDate("2026-10-05")
|
||||
|
||||
// shopEnv is a shopping service over a scheduler env's store. Its job
|
||||
// runner never starts, so Generate's title jobs stay queued and every
|
||||
// Supply run stays untitled until Freeze titles it, which fails.
|
||||
type shopEnv struct {
|
||||
e *env
|
||||
svc *shopping.Service
|
||||
chat *chattest.Chat
|
||||
cs *chat.Service
|
||||
plans *planEnv
|
||||
logs *syncBuffer
|
||||
}
|
||||
|
||||
// shopping builds a shopping service over e, with Regulars to plan from.
|
||||
func (e *env) shopping() *shopEnv {
|
||||
e.t.Helper()
|
||||
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no AI in this test")
|
||||
}
|
||||
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// list plans the week starting weekStart and generates its Supply run.
|
||||
func (s *shopEnv) list(weekStart store.LocalDate) {
|
||||
s.e.t.Helper()
|
||||
s.plans.planWeek(weekStart)
|
||||
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook stores the fake Google Chat webhook.
|
||||
func (s *shopEnv) webhook() {
|
||||
s.e.t.Helper()
|
||||
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the Supply run of the week starting weekStart.
|
||||
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
|
||||
s.e.t.Helper()
|
||||
l, err := s.svc.Get(context.Background(), weekStart)
|
||||
if err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// wantLogged fails unless the service logged want and logged no error.
|
||||
func (s *shopEnv) wantLogged(want string) {
|
||||
s.e.t.Helper()
|
||||
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
|
||||
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
|
||||
// schedule posts the coming week's Supply run, W43's, to Google Chat
|
||||
// through SendToChat, in that week's thread, and records sent_to_chat_at;
|
||||
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
|
||||
// the coming week's start. It posts once.
|
||||
func TestChatAutosendSendsComingWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
// Saturday 07:00: the previous Saturday's period is past the
|
||||
// catch-up window, so it is recorded as missed.
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 7, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
|
||||
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 17, 8, 0))
|
||||
posts := s.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
|
||||
}
|
||||
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
|
||||
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
|
||||
}
|
||||
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
|
||||
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
|
||||
}
|
||||
if s.get(w43).SentToChatAt == nil {
|
||||
t.Error("sent_to_chat_at not recorded")
|
||||
}
|
||||
s.wantLogged("Supply run sent to Google Chat")
|
||||
s.wantLogged("period=list_ready:chat:2026-10-19")
|
||||
|
||||
r.runUntil(at(2026, 10, 24, 7, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("a week on: %d posts, want 1", n)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
// TestChatAutosendFollowsSettings: chat_autosend_dow and
|
||||
// chat_autosend_time move the send without a restart, here to Friday
|
||||
// 18:00, still for the coming week.
|
||||
func TestChatAutosendFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
|
||||
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 16, 17, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 {
|
||||
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 16, 18, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
|
||||
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("Saturday: %d posts, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSkips: the schedule posts nothing, records no send and
|
||||
// fails nothing when the coming week's Supply run was already sent, when
|
||||
// the week has no Supply run, when list_ready is switched off for Google
|
||||
// Chat, and when no webhook is stored. Each period is still claimed and
|
||||
// the reason logged.
|
||||
func TestChatAutosendSkips(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, s *shopEnv)
|
||||
// posts is how many posts the setup itself made.
|
||||
posts int
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "already sent",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
posts: 1,
|
||||
reason: shopping.AutosendAlreadySent,
|
||||
},
|
||||
{
|
||||
name: "no list",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w42)
|
||||
s.webhook()
|
||||
},
|
||||
reason: shopping.AutosendNoList,
|
||||
},
|
||||
{
|
||||
name: "chat off for list_ready",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
reason: shopping.AutosendChatOff,
|
||||
},
|
||||
{
|
||||
name: "no webhook",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
},
|
||||
reason: shopping.AutosendNotConfigured,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
c.setup(t, s)
|
||||
var sentBefore *string
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil {
|
||||
sentBefore = l.SentToChatAt
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != c.posts {
|
||||
t.Errorf("posts = %d, want %d", n, c.posts)
|
||||
}
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
|
||||
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
|
||||
}
|
||||
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
|
||||
// period's error, logged as such; the period stays claimed and is not
|
||||
// retried, and sent_to_chat_at is not recorded.
|
||||
func TestChatAutosendFailureSurfaces(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
s.chat.SetStatus(500)
|
||||
|
||||
var logs syncBuffer
|
||||
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(at(2026, 10, 17, 7, 0))
|
||||
scheduler.SetClock(sched, clock.Now, clock.After)
|
||||
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
sched.Wait()
|
||||
})
|
||||
if err := sched.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &running{t: t, clock: clock, stop: cancel}
|
||||
r.idle()
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("posts = %d, want the one refused", n)
|
||||
}
|
||||
if s.get(w43).SentToChatAt != nil {
|
||||
t.Error("sent_to_chat_at recorded for a refused send")
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
|
||||
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
func equalPtr(a, b *string) bool {
|
||||
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||
}
|
||||
|
||||
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
|
||||
// freezes exactly the week that has just ended into the Supply archive,
|
||||
// not the week before it nor the new week; the next Monday freezes the
|
||||
// next week; a Monday whose previous week has no Supply run freezes
|
||||
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
|
||||
// Monday.
|
||||
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w41)
|
||||
s.list(w42)
|
||||
s.list(w43)
|
||||
|
||||
status := func() []string {
|
||||
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
|
||||
}
|
||||
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
|
||||
|
||||
// Sunday 23:00: last Monday's rollover is past the catch-up window and
|
||||
// recorded as missed, so W41 is never frozen by it.
|
||||
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
|
||||
r.runUntil(at(2026, 10, 19, 0, 4))
|
||||
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 0, 5))
|
||||
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
|
||||
if s.get(w42).Title != nil {
|
||||
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
|
||||
}
|
||||
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 26, 0, 5))
|
||||
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
|
||||
|
||||
r.runUntil(at(2026, 11, 2, 0, 5))
|
||||
s.wantLogged("week rollover froze nothing: the week has no Supply run")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
|
||||
}
|
||||
|
||||
// aiRun stores an ai_runs row created at created and returns its id.
|
||||
func (e *env) aiRun(created time.Time) string {
|
||||
e.t.Helper()
|
||||
id := store.NewID()
|
||||
ts := store.Timestamp{Time: created.UTC()}.String()
|
||||
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
|
||||
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
|
||||
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
|
||||
CreatedAt: ts, UpdatedAt: ts,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// aiRuns returns the ids of the ai_runs rows, oldest first.
|
||||
func (e *env) aiRuns() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
|
||||
// ai_runs rows created more than 30 days before, and no younger row; its
|
||||
// periods are ai_runs_purge:YYYY-MM-DD.
|
||||
func TestAIRunsPurgeDaily(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
|
||||
edge := e.aiRun(at(2026, 9, 12, 2, 59))
|
||||
young := e.aiRun(at(2026, 9, 12, 3, 1))
|
||||
recent := e.aiRun(at(2026, 10, 11, 12, 0))
|
||||
|
||||
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
|
||||
r := e.start(at(2026, 10, 12, 2, 0), sch)
|
||||
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 2, 59))
|
||||
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
|
||||
r.runUntil(at(2026, 10, 12, 3, 0))
|
||||
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
|
||||
r.runUntil(at(2026, 10, 13, 3, 0))
|
||||
wantKeys(t, "next day", e.aiRuns(), []string{recent})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
|
||||
// logger.
|
||||
func TestPurgeScheduleRejectsNil(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,20 @@ func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// postPhotoBackfill starts a photo backfill over every dinner without a
|
||||
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
|
||||
// without waiting for the job.
|
||||
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := svc.StartPhotoBackfill(r.Context())
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "start photo backfill", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
||||
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -93,7 +93,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -556,3 +556,43 @@ func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
||||
t.Errorf("listed = %v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
|
||||
// No model reply is scripted, so grounding on creation fails.
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
waitJob(t, e.h, created["grounding_job_id"].(string))
|
||||
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
|
||||
t.Fatalf("dinner = %v, want failed without a photo", d)
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
|
||||
jobID, _ := started["id"].(string)
|
||||
if rec.Code != http.StatusAccepted || jobID == "" {
|
||||
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
|
||||
}
|
||||
job := waitJob(t, e.h, jobID)
|
||||
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
|
||||
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
|
||||
}
|
||||
d := listedDinner(t, e.h, id)
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
|
||||
t.Errorf("dinner = %v, want grounded with a photo", d)
|
||||
}
|
||||
|
||||
// The backfill is started only by its own endpoint.
|
||||
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
|
||||
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestGetHome(t *testing.T) {
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||
"timezone": loc.String(),
|
||||
"tonight": nil,
|
||||
"sideQuestCount": 0,
|
||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||
|
||||
@@ -65,7 +65,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
|
||||
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
|
||||
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
|
||||
apiV1.Post("/settings/photo-backfill", postPhotoBackfill(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||
|
||||
@@ -174,7 +174,7 @@ func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
@@ -39,11 +40,17 @@ const (
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
return s.freeze(ctx, weekStart, s.now())
|
||||
}
|
||||
|
||||
// freeze is Freeze with the week ended by instant now rather than by the
|
||||
// service's clock.
|
||||
func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
thisWeek, err := weekAt(ctx, q, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
stamp := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
@@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
return weekAt(ctx, q, s.now())
|
||||
}
|
||||
|
||||
// weekAt returns the Monday of the household's week at instant t, in its
|
||||
// time zone.
|
||||
func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
@@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate,
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
return household.WeekStart(household.Today(t, zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
|
||||
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// RolloverTime is when, every Monday in the household zone, week_rollover
|
||||
// freezes the week that has just ended. It is fixed, not a setting.
|
||||
const RolloverTime = "00:05"
|
||||
|
||||
// Reasons Autosend posts nothing.
|
||||
const (
|
||||
AutosendNotConfigured = "no Google Chat webhook is configured"
|
||||
AutosendChatOff = "list_ready is switched off for Google Chat"
|
||||
AutosendNoList = "the week has no Supply run"
|
||||
AutosendAlreadySent = "the Supply run was already sent to Google Chat"
|
||||
AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed"
|
||||
)
|
||||
|
||||
// AutosendKey identifies the automatic send of the Supply run of the week
|
||||
// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the
|
||||
// shopping_chat_autosend period key claimed in schedule_runs. The send
|
||||
// itself is recorded in the list's sent_to_chat_at.
|
||||
func AutosendKey(weekStart store.LocalDate) string {
|
||||
return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String()
|
||||
}
|
||||
|
||||
// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the
|
||||
// household's chat_autosend_dow and chat_autosend_time it sends the Supply
|
||||
// run of the coming week, the week after the fire date's, to Google Chat
|
||||
// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's,
|
||||
// claimed under AutosendKey of the week's Monday. A send that fails is the
|
||||
// period's error and is not retried; the household can still send it from
|
||||
// the Supply run.
|
||||
func (s *Service) ChatAutosendSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.ShoppingChatAutosend,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime)
|
||||
},
|
||||
PeriodKey: func(p scheduler.Period) string {
|
||||
return AutosendKey(p.Week)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
skipped, err := s.Autosend(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := s.logger.With("period", p.Key, "week_start", p.Week.String())
|
||||
if skipped != "" {
|
||||
log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped)
|
||||
return nil
|
||||
}
|
||||
log.InfoContext(ctx, "Supply run sent to Google Chat")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Autosend sends the Supply run of the week starting weekStart to Google
|
||||
// Chat with SendToChat, as the household's send does, when a webhook is
|
||||
// stored, list_ready's Google Chat channel is switched on, and the week has
|
||||
// a Supply run not yet sent and with no send unconfirmed. Otherwise it
|
||||
// posts nothing and returns which of those reasons stopped it; it never
|
||||
// resends. A send that fails is SendToChat's error.
|
||||
func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) {
|
||||
settings, err := s.chat.Get(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if settings.URLMasked == nil {
|
||||
return AutosendNotConfigured, nil
|
||||
}
|
||||
on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if !on {
|
||||
return AutosendChatOff, nil
|
||||
}
|
||||
l, err := s.Get(ctx, weekStart)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
return AutosendNoList, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case l.SentToChatAt != nil:
|
||||
return AutosendAlreadySent, nil
|
||||
case l.ChatSendUnconfirmedAt != nil:
|
||||
return AutosendUnconfirmed, nil
|
||||
}
|
||||
if _, err := s.SendToChat(ctx, weekStart, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// RolloverSchedule is the week_rollover schedule: every Monday at
|
||||
// RolloverTime it freezes the Supply run of the week before the fire
|
||||
// date's into the Supply archive with Freeze, the week ending by the fire
|
||||
// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the
|
||||
// fire date's week. A week with no Supply run has nothing to freeze, and
|
||||
// the period says so; a freeze that fails is the period's error and is not
|
||||
// retried.
|
||||
func (s *Service) RolloverSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.WeekRollover,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Monday, RolloverTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
y, m, d := p.Week.Date()
|
||||
last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
err := s.freeze(ctx, last, p.FireAt)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run",
|
||||
"period", p.Key, "week_start", last.String())
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -516,7 +516,7 @@ func TestGenerateManualRecipe(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+97
-37
@@ -22,6 +22,9 @@ import (
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
|
||||
// for, which keys its new_suggestions; empty for any other trigger.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
@@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// 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.
|
||||
// instead. The batch ends done or failed. 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.
|
||||
//
|
||||
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||
// in the transaction that first adds Side quests to the deck, so the
|
||||
// household is told of every Side quest stored, whether or not the batch
|
||||
// then ends done: a later round, or recording the batch's end, may fail.
|
||||
// A replenish batch, which tops up the deck as the household swipes, never
|
||||
// notifies. See store.
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
// noticeOf). A scheduled or manual batch notifies: it holds every round's
|
||||
// grounded Side quests and, once the last round is done, stores them all
|
||||
// in one transaction with one new_suggestions counting and naming every
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
// See store.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
|
||||
if err != nil {
|
||||
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||
res, err := s.fill(ctx, batch.Trigger, p, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. When notifies is true, store emits new_suggestions
|
||||
// with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||
// fill does runBatch's work for job of a batch with trigger, returning a
|
||||
// *jobs.Failure for a failure the household may see. A batch with a notice
|
||||
// is stored once, when its last round is done; any other batch round by
|
||||
// round.
|
||||
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batchID := job.BatchID
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
note, err := noticeOf(trigger, job, in.Context.Today)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
var week *store.LocalDate
|
||||
if notifies {
|
||||
w := household.WeekStart(in.Context.Today)
|
||||
week = &w
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
// held are a notifying batch's grounded Side quests, not yet stored.
|
||||
var held []groundedQuest
|
||||
for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), 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, week, grounded)
|
||||
if err != nil {
|
||||
if note != nil {
|
||||
held = append(held, grounded...)
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); 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
|
||||
}
|
||||
}
|
||||
if note != nil {
|
||||
if err := s.storeInto(ctx, &res, note, held); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
ids, err := s.store(ctx, res.BatchID, note, quests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Dropped += len(quests) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// notice is how a batch's new_suggestions is keyed: by id, as
|
||||
// new_suggestions:{id}, for the week starting on week.
|
||||
type notice struct {
|
||||
id string
|
||||
week store.LocalDate
|
||||
}
|
||||
|
||||
// noticeOf returns the notice of job, run for a batch with trigger on
|
||||
// household-local date today, or nil for a replenish batch, which never
|
||||
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
|
||||
// for ("2026-W41"), not the week it happens to run in, so each week's
|
||||
// schedule tells the household once even when its batch runs late; a
|
||||
// manual batch is keyed by its batch id, for the week of today. A job
|
||||
// whose week does not fit its trigger is an error.
|
||||
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
|
||||
if trigger != TriggerScheduled {
|
||||
if job.Week != "" {
|
||||
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
|
||||
}
|
||||
if trigger == TriggerReplenish {
|
||||
return nil, nil
|
||||
}
|
||||
return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil
|
||||
}
|
||||
week, err := store.ParseLocalDate(job.Week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
|
||||
}
|
||||
if household.WeekStart(week) != week {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
|
||||
}
|
||||
return ¬ice{id: household.PeriodKey(week), week: week}, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
@@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||
// naming those stored is emitted in the same transaction, for the week
|
||||
// starting week, so the Side quests and their notification commit together
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
// transaction, so the Side quests and their notification commit together
|
||||
// or not at all: a notification that cannot be stored fails the store, and
|
||||
// the batch with it. The event's key makes the first round to store Side
|
||||
// quests the one that notifies; a later round's emit stores nothing.
|
||||
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||
// the batch with it. A notifying batch calls store once, with all its
|
||||
// Side quests, so the count is the batch's (see runBatch). This is the
|
||||
// only place new_suggestions is emitted.
|
||||
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
if week == nil || len(stored) == 0 {
|
||||
if note == nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if week != nil && len(stored) > 0 {
|
||||
if note != nil && len(stored) > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return ids, nil
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// WeeklySchedule is the suggestions_weekly schedule: at the household's
|
||||
// suggestions_dow and suggestions_time it starts a scheduled batch for the
|
||||
// ISO week of the fire date, its period scheduler.Weekly keys
|
||||
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
|
||||
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
|
||||
// that fails before adding any, as when the AI fails, sends nothing. When a
|
||||
// batch is already pending or running the period starts none, and says so.
|
||||
func (s *Service) WeeklySchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
b, err := s.StartScheduledBatch(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.Created {
|
||||
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
|
||||
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -154,17 +155,35 @@ type Batch struct {
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// StartBatch creates a manual or replenish batch and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
// or running, which it returns instead with Created false. A scheduled
|
||||
// batch is started by StartScheduledBatch.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if trigger == TriggerScheduled {
|
||||
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
|
||||
}
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
return s.start(ctx, trigger, "")
|
||||
}
|
||||
|
||||
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
|
||||
// starting on Monday week, which keys its new_suggestions (see noticeOf).
|
||||
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
|
||||
if week.IsZero() || household.WeekStart(week) != week {
|
||||
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
|
||||
}
|
||||
return s.start(ctx, TriggerScheduled, week.String())
|
||||
}
|
||||
|
||||
// start does StartBatch's work for a batch with trigger and the job week.
|
||||
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
b, err = s.startBatch(ctx, tx, trigger, week)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, 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. 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) {
|
||||
// startBatch does StartBatch's work in tx, for a batch with trigger whose
|
||||
// job has week (BatchJob.Week): the batch and the job that runs it are
|
||||
// committed together, so a batch is never seen without its job. 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, week string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
cur, err := s.activeBatch(ctx, q)
|
||||
if err != nil {
|
||||
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
@@ -346,6 +366,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// PendingCount is how many Side quests are pending on the deck. Unlike
|
||||
// Deck it only reads: it does not fail orphaned batches, so a reader such
|
||||
// as Home never writes.
|
||||
func (s *Service) PendingCount(ctx context.Context) (int, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
@@ -428,7 +459,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// scheduledWeek is the week the tests' scheduled batches are for: the
|
||||
// Monday of 2026-W41.
|
||||
var scheduledWeek = mustDate("2026-10-05")
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// startBatch starts a batch with trigger, a scheduled one for
|
||||
// scheduledWeek, and returns it with the key its new_suggestions has.
|
||||
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
|
||||
t.Helper()
|
||||
if trigger == suggest.TriggerScheduled {
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:2026-W41"
|
||||
}
|
||||
b, err := e.svc.StartBatch(t.Context(), trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:" + b.ID
|
||||
}
|
||||
|
||||
func (e *env) batchStatus(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||
// replenish batch, or one that adds none, stores nothing.
|
||||
// quests stores new_suggestions counting and naming them, keyed by the
|
||||
// scheduled ISO week or the manual batch, for that week or the current
|
||||
// one; a replenish batch, or one that adds none, stores nothing.
|
||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||
)
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
@@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
|
||||
if tc.trigger == suggest.TriggerScheduled {
|
||||
week = scheduledWeek.String()
|
||||
}
|
||||
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
|
||||
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
|
||||
// the first round to add Side quests, so a batch whose later round fails
|
||||
// still tells the household of those in the deck, once; a later round that
|
||||
// succeeds adds no second notification; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
|
||||
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
|
||||
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
|
||||
// F.
|
||||
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
|
||||
t.Helper()
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
switch {
|
||||
case i == suggest.BatchSize && !lastGrounds:
|
||||
// A page is found, but no normalize_recipe reply is scripted
|
||||
// for it, so the AI call grounding it fails.
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
case i != 1:
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
}
|
||||
|
||||
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
|
||||
// two grounding rounds stores new_suggestions once, when it completes,
|
||||
// counting and naming every Side quest it stored, not just the first
|
||||
// round's; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
lastFails bool
|
||||
notify bool
|
||||
trigger string
|
||||
notify bool
|
||||
}{
|
||||
{suggest.TriggerScheduled, true, true},
|
||||
{suggest.TriggerManual, true, true},
|
||||
{suggest.TriggerManual, false, true},
|
||||
{suggest.TriggerReplenish, true, false},
|
||||
{suggest.TriggerScheduled, true},
|
||||
{suggest.TriggerManual, true},
|
||||
{suggest.TriggerReplenish, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// The first round grounds BatchSize Side quests but B; the
|
||||
// second round grounds F.
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
if tc.lastFails {
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.twoRoundBatch(t, true)
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, want succeeded", job.Status)
|
||||
}
|
||||
job := e.waitJob(t, b.JobID)
|
||||
want, deck := jobs.StatusSucceeded, suggest.BatchSize
|
||||
if tc.lastFails {
|
||||
want, deck = jobs.StatusFailed, suggest.BatchSize-1
|
||||
}
|
||||
if job.Status != want {
|
||||
t.Fatalf("batch job %s, want %s", job.Status, want)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != deck {
|
||||
t.Fatalf("deck = %v, want %d Side quests", got, deck)
|
||||
deck := e.deckNames(t)
|
||||
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
|
||||
t.Fatalf("deck = %v, want %v", deck, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if !tc.notify {
|
||||
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||
want := fmt.Sprintf("%d new Side quests are in", len(deck))
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
|
||||
// in a batch's second grounding round, a scheduled or manual batch stores
|
||||
// none of the first round's Side quests and sends no new_suggestions, so
|
||||
// no Side quest is left in the deck unannounced; a replenish batch, which
|
||||
// never notifies, keeps the first round's in the deck.
|
||||
func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
deck int
|
||||
}{
|
||||
{suggest.TriggerScheduled, 0},
|
||||
{suggest.TriggerManual, 0},
|
||||
{suggest.TriggerReplenish, suggest.BatchSize - 1},
|
||||
} {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.twoRoundBatch(t, false)
|
||||
|
||||
b, _ := e.startBatch(t, tc.trigger)
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed {
|
||||
t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
|
||||
}
|
||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||
t.Errorf("batch status = %s, want failed", got)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != tc.deck {
|
||||
t.Errorf("deck = %v, want %d Side quests", got, tc.deck)
|
||||
}
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
|
||||
t.Errorf("new_suggestions rows = %+v, want none", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1021,7 +1102,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
|
||||
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
||||
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1069,14 +1150,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
||||
}
|
||||
// Its Side quests stay in the deck, and the household was told of them.
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
|
||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||
}
|
||||
}
|
||||
@@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
|
||||
// StartScheduledBatch, for a Monday.
|
||||
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
|
||||
t.Error("StartBatch started a scheduled batch with no week")
|
||||
}
|
||||
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
|
||||
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
|
||||
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
|
||||
}
|
||||
}
|
||||
if n := e.activeBatches(t); n != 0 {
|
||||
t.Errorf("%d active batches, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
|
||||
// new_suggestions is keyed by the week it was scheduled for, not the week
|
||||
// it runs in, so a batch caught up after its week ended still tells the
|
||||
// household of that week's Side quests and leaves the next week's key free.
|
||||
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.recipe(t, "Chili Verde")
|
||||
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
|
||||
// A week long past: the batch runs today, weeks later.
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
|
||||
rows[0].Payload.Title != "1 new Side quest is in" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// noCalendar is a plan.Calendar for tests that never push.
|
||||
type noCalendar struct{}
|
||||
|
||||
|
||||
@@ -181,3 +181,65 @@ WHERE grounding_status = 'pending'
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
-- name: ListPhotoBackfillDinners :many
|
||||
-- The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
-- photo and no pasted recipe_url (the household's own link is never
|
||||
-- replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
-- the list to that dinner. Grounded, manual and pending dinners are never
|
||||
-- listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
SELECT * FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND id = COALESCE(sqlc.narg(id), id)
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: GetActivePhotoBackfillJob :one
|
||||
-- The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
-- running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
-- there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
SELECT id
|
||||
FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.dinner_id') IS NULL
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GroundFailedDinner :execrows
|
||||
-- Records a photo backfill's grounding of a dinner it listed, while the
|
||||
-- dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
-- the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
-- renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
-- GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
-- member already set.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
-- name: RefailDinnerGrounding :execrows
|
||||
-- Records why a photo backfill could not ground a dinner it listed, under
|
||||
-- the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
UPDATE dinners
|
||||
SET grounding_error = sqlc.arg(grounding_error),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- name: ClaimScheduleRun :execrows
|
||||
-- Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
-- this caller owns the period, 0 rows means it was already claimed.
|
||||
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (schedule, period_key) DO NOTHING;
|
||||
@@ -52,6 +52,8 @@ function listed(is_favorite: boolean) {
|
||||
is_favorite,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -20,8 +20,12 @@ export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
|
||||
|
||||
// Regular is the part of a dinner the Regulars screens read. photo_id is
|
||||
// the stored photo's id, from photo_url. pending is true while an
|
||||
// optimistic add waits for the server; its id is not the server's.
|
||||
// the stored photo's id, from photo_url. grounding_error is why its
|
||||
// grounding failed, a reason code, set only while grounding_status is
|
||||
// failed. photo_backfill_eligible is whether a photo backfill (Search
|
||||
// again) searches for it: never for a dinner with a pasted recipe link.
|
||||
// pending is true while an optimistic add waits for the server;
|
||||
// its id is not the server's.
|
||||
export type Regular = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -31,6 +35,8 @@ export type Regular = {
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
grounding_status: GroundingStatus
|
||||
grounding_error: string | null
|
||||
photo_backfill_eligible: boolean
|
||||
pending?: true
|
||||
}
|
||||
|
||||
@@ -113,6 +119,9 @@ export function parseDinner(where: string, body: unknown): Regular {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
if (typeof body.photo_backfill_eligible !== 'boolean') {
|
||||
throw new Error(`${where}: photo_backfill_eligible is not a boolean`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
@@ -122,6 +131,11 @@ export function parseDinner(where: string, body: unknown): Regular {
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
grounding_error: nullable(body.grounding_error, (v) => {
|
||||
if (typeof v !== 'string') throw new Error(`${where}: grounding_error is not a string`)
|
||||
return v
|
||||
}),
|
||||
photo_backfill_eligible: body.photo_backfill_eligible,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +239,8 @@ export function useAddRegular() {
|
||||
is_favorite: false,
|
||||
photo_id: undefined,
|
||||
grounding_status: 'pending',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
pending: true,
|
||||
}
|
||||
await changeOptimistically(queryClient, (list) => [...list, optimistic])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
@@ -20,6 +20,7 @@ const NOTHING_YET = {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -72,6 +73,31 @@ describe('HomePage', () => {
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
|
||||
// setTimeout moves with real time, and the test jumps to midnight.
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
// 23:58 on Friday Oct 2 in New York, the household's zone.
|
||||
vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
|
||||
const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
|
||||
let reads = 0
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(reads).toBe(1)
|
||||
|
||||
// A minute before midnight Home is not read again.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(reads).toBe(1)
|
||||
// At midnight it is, and shows the new day.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
|
||||
@@ -232,6 +232,45 @@ describe('RotationCheck', () => {
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives; a notification tap there reads the checks again.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) =>
|
||||
Promise.resolve(
|
||||
String(input) === ME_PATH
|
||||
? jsonResponse(200, ME)
|
||||
: String(input) === HOME_PATH
|
||||
? jsonResponse(200, HOME)
|
||||
: answers.shift()!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await renderHome()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
|
||||
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens Home with the card from a rotation_review push notification', async () => {
|
||||
stubApi([SHAKSHUKA])
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
@@ -44,17 +44,12 @@ function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
||||
// household answers it — nothing is decided for them. An answer is applied
|
||||
// at once: Home confirms it and shows the next pending check, if any, while
|
||||
// the answer is saved; the answers are locked until it settles. A failed
|
||||
// answer puts its check back with an ErrorBanner.
|
||||
// answer puts its check back with an ErrorBanner. A failed read shows an
|
||||
// ErrorBanner, beside the checks last read: a card already shown stays
|
||||
// until it is answered.
|
||||
export function RotationCheck() {
|
||||
const reviews = useRotationReviews()
|
||||
const decide = useDecideRotationReview()
|
||||
if (reviews.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void reviews.refetch()}>
|
||||
Couldn't load the Rotation check. {reviews.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
|
||||
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
||||
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
||||
@@ -65,6 +60,11 @@ export function RotationCheck() {
|
||||
{CONFIRMATION[answered.decision]}
|
||||
</p>
|
||||
)}
|
||||
{reviews.isError && (
|
||||
<ErrorBanner onRetry={() => void reviews.refetch()}>
|
||||
Couldn't load the Rotation check. {reviews.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
|
||||
{next !== undefined && (
|
||||
<RotationCard
|
||||
|
||||
@@ -28,7 +28,7 @@ const UPDATES = {
|
||||
type: 'plan_ready',
|
||||
title: 'Your week is drafted',
|
||||
body: 'Take a look at the Draft board.',
|
||||
url: '/plan',
|
||||
url: '/plan?week=2026-10-12',
|
||||
created_at: minutesAgo(5),
|
||||
},
|
||||
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||
@@ -311,7 +311,7 @@ describe('Recent updates sheet', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, '/plan'],
|
||||
[0, '/plan?week=2026-10-12'],
|
||||
[1, '/discover'],
|
||||
[2, '/plan/shopping?week=2026-10-05'],
|
||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseUpdates } from './api.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { parseHome, parseUpdates } from './api.ts'
|
||||
|
||||
const origin = 'https://mealquest.example'
|
||||
const where = 'GET /api/v1/updates'
|
||||
@@ -37,3 +38,15 @@ describe('parseUpdates', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHome', () => {
|
||||
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
|
||||
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
|
||||
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
|
||||
)
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
|
||||
'GET /api/v1/home: timezone undefined is not an IANA zone',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+35
-4
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { weekLabel } from '../plan/week.ts'
|
||||
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
|
||||
import { appPath } from '../push/notification.ts'
|
||||
|
||||
export const HOME_PATH = '/api/v1/home'
|
||||
@@ -27,11 +28,13 @@ export type Tonight = { id: string; name: string; photo_id: string | undefined;
|
||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||
|
||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
||||
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
|
||||
// next day; tonight is null when today has no plan; sideQuestCount is how
|
||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
timezone: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
@@ -77,6 +80,9 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
|
||||
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
|
||||
}
|
||||
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||
}
|
||||
@@ -84,6 +90,7 @@ export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
timezone: body.timezone,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||
@@ -91,13 +98,37 @@ export function parseHome(where: string, body: unknown): Home {
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home).
|
||||
// isZone says whether zone is an IANA zone this browser knows.
|
||||
function isZone(zone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: zone })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
|
||||
// again at the household's next local midnight, counted from each read, so
|
||||
// a Home left open does not keep yesterday's date and dinner.
|
||||
export function useHome() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
const queryClient = useQueryClient()
|
||||
const home = useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||
})
|
||||
const zone = home.data?.timezone
|
||||
const readAt = home.dataUpdatedAt
|
||||
useEffect(() => {
|
||||
if (zone === undefined) return
|
||||
const timer = setTimeout(
|
||||
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
|
||||
untilLocalMidnight(zone, new Date()),
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [queryClient, zone, readAt])
|
||||
return home
|
||||
}
|
||||
|
||||
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||
|
||||
@@ -114,6 +114,8 @@ function dinner(id: string, name: string, difficulty: string | null, minutes: nu
|
||||
is_favorite: false,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,16 +162,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||
// screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route
|
||||
path="/plan"
|
||||
element={
|
||||
<>
|
||||
<PlanPage />
|
||||
<Searched />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -190,6 +201,11 @@ function Opened() {
|
||||
)
|
||||
}
|
||||
|
||||
// Searched shows the Plan's search, the week it keeps in ?week=.
|
||||
function Searched() {
|
||||
return <p data-testid="search">{useLocation().search}</p>
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
@@ -1114,6 +1130,30 @@ describe('PlanPage', () => {
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
|
||||
stubServer({
|
||||
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
...lists,
|
||||
})
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
// The week buttons keep ?week= on the week shown.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
|
||||
})
|
||||
|
||||
it('says so when ?week= is not a Monday, and reads no plan', async () => {
|
||||
const fetchMock = stubServer({ ...lists })
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
|
||||
// ?week=, as a plan_ready update links it, else on next week once this
|
||||
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// The default week is chosen once, not on every render: the plan_draft
|
||||
// time passing does not move the page to another week.
|
||||
const [opened, setOpened] = useState<string | null>(null)
|
||||
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
|
||||
const week = chosen ?? opened
|
||||
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
|
||||
|
||||
let content
|
||||
if (settings.isError) {
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
} else if (settings.isError) {
|
||||
content = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import {
|
||||
addDays,
|
||||
dayAndDate,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
untilLocalMidnight,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart,
|
||||
} from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -72,4 +84,45 @@ describe('week helpers', () => {
|
||||
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
|
||||
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
|
||||
})
|
||||
|
||||
it('says whether a date is a real Monday', () => {
|
||||
expect(isMonday('2026-10-05')).toBe(true)
|
||||
expect(isMonday('2026-10-07')).toBe(false)
|
||||
expect(isMonday('2026-02-30')).toBe(false)
|
||||
expect(isMonday('next week')).toBe(false)
|
||||
})
|
||||
|
||||
it('counts the time to the next midnight in the household zone, not UTC', () => {
|
||||
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
|
||||
// At midnight itself the next is a day away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
|
||||
// The same instant is 13:00 in Tokyo: eleven hours to go.
|
||||
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('counts a day shortened by the spring clock change as 23 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the
|
||||
// next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour)
|
||||
// 03:30 EDT, after the change: 20.5 hours to midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour)
|
||||
// At midnight starting the 23-hour day, the next midnight is 23 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour)
|
||||
// Noon EST the day before: the change is after the coming midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour)
|
||||
})
|
||||
|
||||
it('counts a day lengthened by the autumn clock change as 25 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour)
|
||||
})
|
||||
|
||||
it('starts the next day at the clock change when the clocks skip midnight', () => {
|
||||
// Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z);
|
||||
// at 23:00 -04 on September 5 the next day starts an hour later.
|
||||
expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
||||
return addDays(date, -sinceMonday)
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
export function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekStart(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||
export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
@@ -115,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||
}
|
||||
|
||||
// wallClock is the household-local wall clock at instant t in IANA zone,
|
||||
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
|
||||
function wallClock(zone: string, t: number): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date(t))
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
||||
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
|
||||
return wall + (((t % 1000) + 1000) % 1000)
|
||||
}
|
||||
|
||||
// untilLocalMidnight is how many milliseconds after instant now the next
|
||||
// local day begins in IANA zone. It finds the actual instant of the next
|
||||
// day's midnight, so a day shortened or lengthened by a change of UTC offset
|
||||
// is counted as it is. The candidates are that midnight at the zone's offset
|
||||
// now and at the offset that candidate lands on; the earliest that falls on
|
||||
// the next day is its start, which is the offset change itself when the
|
||||
// clocks skip midnight.
|
||||
export function untilLocalMidnight(zone: string, now: Date): number {
|
||||
const t = now.getTime()
|
||||
const tomorrow = addDays(localNow(zone, now).date, 1)
|
||||
const midnight = midnightUTC(tomorrow).getTime()
|
||||
const first = midnight - (wallClock(zone, t) - t)
|
||||
const second = midnight - (wallClock(zone, first) - first)
|
||||
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
|
||||
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
|
||||
return Math.min(...starts) - t
|
||||
}
|
||||
|
||||
@@ -300,3 +300,27 @@
|
||||
.lock {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Find missing photos: its hint, button and progress, padded like a sheet. */
|
||||
.card.backfill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Why a Regular's recipe was not found. */
|
||||
.reason {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reasonLabel {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ const TACOS = dinnerBody('d1', 'Beef tacos', {
|
||||
photo_url: '/media/photos/p1-400.jpg',
|
||||
})
|
||||
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', {
|
||||
grounding_status: 'failed',
|
||||
grounding_error: 'no_recipe_found',
|
||||
photo_backfill_eligible: true,
|
||||
})
|
||||
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
|
||||
|
||||
function rows() {
|
||||
@@ -496,6 +500,204 @@ describe('RegularsSettingsPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Photo backfill', () => {
|
||||
const BACKFILL = 'POST /api/v1/settings/photo-backfill'
|
||||
const JOB = 'GET /api/v1/jobs/j9'
|
||||
|
||||
// backfillJob is a GET /api/v1/jobs/j9 answer for the photo backfill.
|
||||
function backfillJob(status: string, result: unknown = null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error })
|
||||
}
|
||||
|
||||
// polls answers each poll of the job with the next answer, then the last
|
||||
// one again; an answer may change the server first.
|
||||
function polls(...answers: (() => Response)[]) {
|
||||
let next = 0
|
||||
return () => answers[Math.min(next++, answers.length - 1)]!()
|
||||
}
|
||||
|
||||
function fixSheet() {
|
||||
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
}
|
||||
|
||||
const NO_CANDIDATES = { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }
|
||||
|
||||
it('shows why the recipe was not found in the fix-recipe sheet', async () => {
|
||||
stubServer([PIZZA], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText('No recipe page the search found had a recipe we could use.')).toBeInTheDocument()
|
||||
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers no search again for a pasted link that had no usable recipe', async () => {
|
||||
const linked = { ...PIZZA, grounding_error: 'recipe_unusable', photo_backfill_eligible: false }
|
||||
stubServer([linked], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText('The pasted recipe link has no recipe we could use.')).toBeInTheDocument()
|
||||
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no search again for a dinner with a pasted link whatever its reason', async () => {
|
||||
const linked = { ...PIZZA, grounding_error: 'ai_failed', photo_backfill_eligible: false }
|
||||
stubServer([linked], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText("The AI assistant couldn't read the recipe.")).toBeInTheDocument()
|
||||
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('searches again from the fix-recipe sheet and shows the new reason when it still fails', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: polls(
|
||||
() => backfillJob('running'),
|
||||
() => {
|
||||
server.regulars = [{ ...PIZZA, grounding_error: 'search_failed' }]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'failed', reason: 'search_failed' }] })
|
||||
},
|
||||
),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
expect(await fixSheet().findByRole('button', { name: 'Searching again…' })).toBeDisabled()
|
||||
expect(fixSheet().getByText('Searching again… this can take a few minutes.')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
|
||||
|
||||
expect(await fixSheet().findByText('Searched again: still no recipe found.', {}, { timeout: 3000 })).toBeInTheDocument()
|
||||
expect(await fixSheet().findByText("The recipe search couldn't be reached.")).toBeInTheDocument()
|
||||
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('closes the fix-recipe sheet once searching again finds the recipe and its photo', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => {
|
||||
server.regulars = [{ ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg')
|
||||
})
|
||||
|
||||
it('says when a dinner was not included in the search', async () => {
|
||||
stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
expect(await fixSheet().findByText('This dinner was not included in that search.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('finds missing photos: the placeholder shows until the backfill finds a photo', async () => {
|
||||
const found = { ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }
|
||||
const server = stubServer([TACOS, PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: polls(
|
||||
() => backfillJob('queued'),
|
||||
() => {
|
||||
server.regulars = [TACOS, found]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
|
||||
},
|
||||
),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
// The photoless dinner shows the striped placeholder, not an image.
|
||||
const placeholder = within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })
|
||||
expect(placeholder).not.toHaveAttribute('src')
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Find missing photos' })
|
||||
expectMinHitArea(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(await screen.findByRole('button', { name: 'Finding photos…' })).toBeDisabled()
|
||||
expect(await screen.findByText('Finding missing photos… this can take a few minutes.')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).not.toHaveAttribute('src')
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
|
||||
|
||||
expect(await screen.findByText('Photo search done: 1 photo found.', {}, { timeout: 3000 })).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg'),
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
expect(reads(server.fetchMock)).toBe(2)
|
||||
})
|
||||
|
||||
it('sums up what the backfill did', async () => {
|
||||
stubServer([PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () =>
|
||||
backfillJob('succeeded', {
|
||||
dinners: [
|
||||
{ dinner_id: 'd3', outcome: 'grounded', reason: null },
|
||||
{ dinner_id: 'd5', outcome: 'grounded', reason: null },
|
||||
{ dinner_id: 'd6', outcome: 'failed', reason: 'no_recipe_found' },
|
||||
{ dinner_id: 'd7', outcome: 'skipped', reason: null },
|
||||
],
|
||||
}),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Photo search done: 2 photos found · 1 dinner still needs a recipe · 1 dinner changed meanwhile, left as it was.',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when no dinner was missing a photo', async () => {
|
||||
stubServer([TACOS], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByText('No dinners were missing a photo.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why the backfill stopped', async () => {
|
||||
stubServer([PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('failed', { dinners: [] }, 'internal_error'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search stopped: Something went wrong on the server.')
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('says why the backfill could not start', async () => {
|
||||
stubServer([PIZZA], { [BACKFILL]: () => errorResponse(403, 'csrf_failed') })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search not started: csrf_failed message')
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
|
||||
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||
// Testing Library's waits keep their real timers.
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import { usePhotoBackfill, type BackfillResult, type PhotoBackfill } from './photoBackfill.ts'
|
||||
import styles from './RegularsSettingsPage.module.css'
|
||||
|
||||
// The most minutes a dinner may take, as the dinners API allows: a day.
|
||||
@@ -57,7 +58,8 @@ const LINK = /^https?:\/\//i
|
||||
// method and minutes, favorite heart and whether its recipe was found. The
|
||||
// field at the top adds one by name, or by pasted recipe link with a name
|
||||
// the household gives it; tapping one
|
||||
// edits or removes it, or fixes its recipe when none was found.
|
||||
// edits or removes it, or fixes its recipe when none was found. Find
|
||||
// missing photos searches again for every dinner left without one.
|
||||
export function RegularsSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
@@ -86,6 +88,7 @@ function RegularsManager() {
|
||||
const remove = useRemoveRegular()
|
||||
const favorite = useToggleFavorite()
|
||||
const { refusals, track } = useRefusals()
|
||||
const backfill = usePhotoBackfill()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [linkName, setLinkName] = useState('')
|
||||
const [open, setOpen] = useState<Open>(null)
|
||||
@@ -94,8 +97,10 @@ function RegularsManager() {
|
||||
const isLink = LINK.test(draft.trim())
|
||||
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
||||
const list = regulars.data ?? []
|
||||
// A Regular removed meanwhile closes its sheet.
|
||||
// A Regular removed meanwhile closes its sheet, and one whose recipe was
|
||||
// found meanwhile closes its fix-recipe sheet.
|
||||
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||
const fixing = opened && open.sheet === 'fix' && opened.grounding_status === 'failed' ? opened : undefined
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -188,6 +193,7 @@ function RegularsManager() {
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
|
||||
|
||||
{opened && open.sheet === 'edit' && (
|
||||
<EditRegularSheet
|
||||
@@ -197,12 +203,13 @@ function RegularsManager() {
|
||||
onRemove={() => onRemove(opened)}
|
||||
/>
|
||||
)}
|
||||
{opened && open.sheet === 'fix' && (
|
||||
{fixing && (
|
||||
<FixRecipeSheet
|
||||
key={opened.id}
|
||||
regular={opened}
|
||||
key={fixing.id}
|
||||
regular={fixing}
|
||||
backfill={backfill}
|
||||
onClose={() => setOpen(null)}
|
||||
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
|
||||
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -231,6 +238,88 @@ function GroundingBadge({ status }: { status: GroundingStatus }) {
|
||||
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
|
||||
}
|
||||
|
||||
// groundingReasons says why a dinner's recipe was not found, by its
|
||||
// grounding_error reason code.
|
||||
const groundingReasons: Record<string, string> = {
|
||||
no_recipe_found: 'No recipe page the search found had a recipe we could use.',
|
||||
recipe_unusable: 'The pasted recipe link has no recipe we could use.',
|
||||
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
|
||||
search_failed: "The recipe search couldn't be reached.",
|
||||
ai_failed: "The AI assistant couldn't read the recipe.",
|
||||
interrupted: 'The server stopped while finding the recipe.',
|
||||
grounding_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// groundingReason says why a dinner's recipe was not found; an unknown
|
||||
// reason code is shown as it is.
|
||||
export function groundingReason(code: string): string {
|
||||
return groundingReasons[code] ?? code
|
||||
}
|
||||
|
||||
// backfillJobFailures says why a photo backfill job failed, by its failure
|
||||
// code; an unknown code is shown as it is.
|
||||
const backfillJobFailures: Record<string, string> = {
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// backfillProblem says what went wrong with a photo backfill, or null.
|
||||
function backfillProblem(backfill: PhotoBackfill): string | null {
|
||||
if (backfill.startError) return `Photo search not started: ${backfill.startError.message}`
|
||||
if (backfill.status === 'failed' && backfill.error) {
|
||||
return `Photo search stopped: ${backfillJobFailures[backfill.error.message] ?? backfill.error.message}`
|
||||
}
|
||||
if (backfill.error) return `Couldn't check on the photo search: ${backfill.error.message}`
|
||||
return null
|
||||
}
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`
|
||||
}
|
||||
|
||||
// backfillSummary says what a finished photo backfill did.
|
||||
function backfillSummary(result: BackfillResult): string {
|
||||
const tally = (outcome: string) => result.dinners.filter((dinner) => dinner.outcome === outcome).length
|
||||
if (result.dinners.length === 0) return 'No dinners were missing a photo.'
|
||||
const parts = [count(tally('grounded'), 'photo found', 'photos found')]
|
||||
const failed = tally('failed')
|
||||
if (failed > 0) parts.push(`${count(failed, 'dinner still needs', 'dinners still need')} a recipe`)
|
||||
const skipped = tally('skipped')
|
||||
if (skipped > 0) parts.push(`${count(skipped, 'dinner', 'dinners')} changed meanwhile, left as it was`)
|
||||
return `Photo search done: ${parts.join(' · ')}.`
|
||||
}
|
||||
|
||||
// FindMissingPhotos searches again, by name, for a recipe and photo for
|
||||
// every dinner left without a photo because its recipe was not found.
|
||||
function FindMissingPhotos({ backfill }: { backfill: PhotoBackfill }) {
|
||||
const headingId = useId()
|
||||
const problem = backfillProblem(backfill)
|
||||
return (
|
||||
<section aria-labelledby={headingId} className={styles.section}>
|
||||
<h2 id={headingId} className={styles.groupLabel}>
|
||||
Missing photos
|
||||
</h2>
|
||||
<Card className={`${styles.card} ${styles.backfill}`}>
|
||||
<p className={styles.hint}>
|
||||
Search again for a recipe and photo for every dinner whose recipe wasn't found. Dinners with a pasted
|
||||
recipe link keep it.
|
||||
</p>
|
||||
<Button disabled={backfill.active} onClick={backfill.start}>
|
||||
{backfill.active ? 'Finding photos…' : 'Find missing photos'}
|
||||
</Button>
|
||||
<p role="status" className={styles.status}>
|
||||
{backfill.active && 'Finding missing photos… this can take a few minutes.'}
|
||||
{backfill.status === 'succeeded' && backfill.result && backfillSummary(backfill.result)}
|
||||
</p>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type RegularRowProps = {
|
||||
regular: Regular
|
||||
onOpen: () => void
|
||||
@@ -447,6 +536,7 @@ const fixPaths = [
|
||||
|
||||
type FixRecipeSheetProps = {
|
||||
regular: Regular
|
||||
backfill: PhotoBackfill
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
@@ -459,10 +549,20 @@ function lines(text: string): string[] {
|
||||
.filter((line) => line !== '')
|
||||
}
|
||||
|
||||
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
|
||||
// up to three recipe pages found for its name, in a pasted recipe link, or
|
||||
// in ingredients and steps typed in.
|
||||
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
// retryOutcomes says what searching again did for this dinner.
|
||||
const retryOutcomes = {
|
||||
grounded: 'Searched again: a recipe and photo were found.',
|
||||
failed: 'Searched again: still no recipe found.',
|
||||
skipped: 'It changed while it was searched again, so it was left as it is.',
|
||||
} as const
|
||||
|
||||
// FixRecipeSheet shows why a Regular's recipe was not found and grounds it:
|
||||
// by searching its name again (a photo backfill), in one of up to three
|
||||
// recipe pages found for its name, in a pasted recipe link, or in
|
||||
// ingredients and steps typed in. Searching again is offered only for a
|
||||
// dinner the backfill searches (photo_backfill_eligible): it never replaces
|
||||
// a pasted link.
|
||||
function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
const [path, setPath] = useState<FixPath>('pick')
|
||||
const ground = useGroundRegular()
|
||||
|
||||
@@ -475,6 +575,15 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
<p className={styles.hint}>
|
||||
We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
|
||||
</p>
|
||||
{regular.grounding_error !== null && (
|
||||
<p className={styles.reason}>
|
||||
<span className={styles.reasonLabel}>Why: </span>
|
||||
{groundingReason(regular.grounding_error)}
|
||||
</p>
|
||||
)}
|
||||
{regular.photo_backfill_eligible && (
|
||||
<RetrySearch id={regular.id} backfill={backfill} disabled={ground.isPending} />
|
||||
)}
|
||||
<Segmented
|
||||
label="How to fix the recipe"
|
||||
options={fixPaths}
|
||||
@@ -504,6 +613,34 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// RetrySearch searches the dinner's name again with a photo backfill, and
|
||||
// says what it did for the dinner once it ends. The backfill searches
|
||||
// every dinner without a photo, this one with them.
|
||||
function RetrySearch({ id, backfill, disabled }: { id: string; backfill: PhotoBackfill; disabled: boolean }) {
|
||||
const problem = backfillProblem(backfill)
|
||||
const tried = backfill.result?.dinners.find((dinner) => dinner.dinner_id === id)
|
||||
let status: string | null = null
|
||||
if (backfill.active) status = 'Searching again… this can take a few minutes.'
|
||||
else if (backfill.status === 'succeeded') {
|
||||
status = tried ? retryOutcomes[tried.outcome] : 'This dinner was not included in that search.'
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" disabled={disabled || backfill.active} onClick={backfill.start}>
|
||||
{backfill.active ? 'Searching again…' : 'Search again'}
|
||||
</Button>
|
||||
<p role="status" className={styles.status}>
|
||||
{status}
|
||||
</p>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
|
||||
const candidates = useRecipeCandidates(id)
|
||||
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { HOME_KEY } from '../home/api.ts'
|
||||
import { planKey } from '../plan/api.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
|
||||
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
|
||||
|
||||
function backfillJob(status: string, result: unknown) {
|
||||
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
|
||||
}
|
||||
|
||||
describe('usePhotoBackfill', () => {
|
||||
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
jsonResponse(202, { id: 'j9' }),
|
||||
backfillJob('running', null),
|
||||
backfillJob('succeeded', RESULT),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const plan = planKey('2026-10-05')
|
||||
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
|
||||
|
||||
act(() => result.current.start())
|
||||
await waitFor(() => expect(result.current.status).toBe('running'))
|
||||
expect(result.current.active).toBe(true)
|
||||
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
|
||||
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBackfillResult', () => {
|
||||
it('refuses a result that is not a photo backfill', () => {
|
||||
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
|
||||
'job: outcome "lost" is not one of grounded, failed, skipped',
|
||||
)
|
||||
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useApi, useJob, type JobStatus } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { HOME_KEY } from '../home/api.ts'
|
||||
|
||||
export const PHOTO_BACKFILL_PATH = '/api/v1/settings/photo-backfill'
|
||||
|
||||
// BACKFILL_JOB_KIND is the kind of the job a photo backfill runs as.
|
||||
export const BACKFILL_JOB_KIND = 'backfill_dinner_photos'
|
||||
|
||||
export const BACKFILL_OUTCOMES = ['grounded', 'failed', 'skipped'] as const
|
||||
export type BackfillOutcome = (typeof BACKFILL_OUTCOMES)[number]
|
||||
|
||||
// BackfillDinner is what a photo backfill did for one dinner. reason is
|
||||
// set only when it failed: the reason code now its grounding_error.
|
||||
export type BackfillDinner = { dinner_id: string; outcome: BackfillOutcome; reason: string | null }
|
||||
|
||||
// BackfillResult is the photo backfill job's result: each dinner it tried,
|
||||
// in the order it tried them.
|
||||
export type BackfillResult = { dinners: BackfillDinner[] }
|
||||
|
||||
// Every query showing a dinner's photo, read again once a backfill ends:
|
||||
// the dinners lists and details, the plans and the home dashboard.
|
||||
const PHOTO_KEYS = [['dinners'], ['plans'], HOME_KEY] as const
|
||||
|
||||
// parseBackfillResult checks a photo backfill job's result, which useJob
|
||||
// passes on unread.
|
||||
export function parseBackfillResult(where: string, body: unknown): BackfillResult {
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: result is not a photo backfill`)
|
||||
return {
|
||||
dinners: body.dinners.map((dinner): BackfillDinner => {
|
||||
if (!isRecord(dinner) || typeof dinner.dinner_id !== 'string' || (dinner.reason !== null && typeof dinner.reason !== 'string')) {
|
||||
throw new Error(`${where}: result has a dinner that is not a backfill outcome`)
|
||||
}
|
||||
return {
|
||||
dinner_id: dinner.dinner_id,
|
||||
outcome: oneOf(where, 'outcome', BACKFILL_OUTCOMES, dinner.outcome),
|
||||
reason: dinner.reason,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// PhotoBackfill is a photo backfill started from this screen, as it goes.
|
||||
export type PhotoBackfill = {
|
||||
// start asks the server for a backfill over every dinner without a
|
||||
// photo. While one is already queued or running the server answers with
|
||||
// that job, so starting never runs two at once.
|
||||
start: () => void
|
||||
// active is true from start until the job ends, except while a poll's
|
||||
// failure leaves its progress unknown: starting again then asks the
|
||||
// server for the job it is running and polls it anew.
|
||||
active: boolean
|
||||
// Why the backfill could not be started.
|
||||
startError: Error | null
|
||||
// The job's status, undefined until it is started and first polled.
|
||||
status: JobStatus | undefined
|
||||
// The job's result, once it has one.
|
||||
result: BackfillResult | null
|
||||
// The job's failure, a poll's failure, or a result that is not a backfill.
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// usePhotoBackfill starts a photo backfill (POST
|
||||
// /api/v1/settings/photo-backfill, 202 {id}) and follows its job with
|
||||
// useJob. When the job ends, every query showing a dinner's photo is read
|
||||
// again, so dinners it grounded show their photo everywhere.
|
||||
export function usePhotoBackfill(): PhotoBackfill {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(BACKFILL_JOB_KIND, jobId)
|
||||
const begin = useMutation({
|
||||
mutationFn: async (): Promise<string> => {
|
||||
const body = await apiFetch<unknown>(PHOTO_BACKFILL_PATH, { method: 'POST' })
|
||||
if (!isRecord(body) || typeof body.id !== 'string') throw new Error(`POST ${PHOTO_BACKFILL_PATH}: response has no job id`)
|
||||
return body.id
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
setJobId(id)
|
||||
job.poll()
|
||||
},
|
||||
})
|
||||
|
||||
const ended = job.status === 'succeeded' || job.status === 'failed'
|
||||
useEffect(() => {
|
||||
if (!ended) return
|
||||
for (const queryKey of PHOTO_KEYS) void queryClient.invalidateQueries({ queryKey })
|
||||
}, [ended, jobId, queryClient])
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (job.result === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseBackfillResult(`GET /api/v1/jobs/${jobId}`, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, jobId])
|
||||
|
||||
return {
|
||||
start: () => begin.mutate(),
|
||||
active: begin.isPending || (jobId !== null && !ended && job.error === null),
|
||||
startError: begin.error,
|
||||
status: job.status,
|
||||
result: parsed.result,
|
||||
error: job.error ?? parsed.error,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekOf(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
dateLabel: 'Friday, Oct 2',
|
||||
timezone: 'America/New_York',
|
||||
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DinnerBody = {
|
||||
photo_url: string | null
|
||||
grounding_status: string
|
||||
grounding_error: string | null
|
||||
photo_backfill_eligible: boolean
|
||||
updated_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -35,6 +36,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
updated_by: null,
|
||||
created_at: '2026-10-01T15:00:00.000Z',
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
|
||||
Reference in New Issue
Block a user