Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b63bd08c | ||
|
|
6956f6f049 | ||
|
|
9978e200e6 | ||
|
|
74e33a6236 | ||
|
|
ea5399098d | ||
|
|
dbb6310112 |
+4
-44
@@ -31,7 +31,6 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"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/push"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
@@ -131,12 +130,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The scheduler is made before the services whose changes wake it.
|
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -158,7 +152,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -199,46 +193,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start fails the jobs a previous process left running before any
|
// Start fails the jobs a previous process left running before any
|
||||||
// worker runs. The job workers, the notification worker and the
|
// worker runs. The job workers and the notification worker stop, and
|
||||||
// scheduler stop, and are waited for, before the database closes.
|
// are waited for, before the database closes.
|
||||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||||
defer func() {
|
defer func() {
|
||||||
stopWorkers()
|
stopWorkers()
|
||||||
sched.Wait()
|
|
||||||
runner.Wait()
|
runner.Wait()
|
||||||
notifier.Wait()
|
notifier.Wait()
|
||||||
}()
|
}()
|
||||||
@@ -254,11 +219,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
||||||
return err
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
|
|||||||
+46
-147
@@ -309,24 +309,20 @@ automatic** (D-021).
|
|||||||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||||||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
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
|
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. A `replenish` batch
|
left. A Side quest no recipe grounds is dropped; each round's grounded
|
||||||
stores each round's grounded ones before the next; a `scheduled` or
|
ones are stored before the next — a dinner with status `suggested`,
|
||||||
`manual` batch, which notifies, holds them and stores them all in one
|
origin `side_quest`, grounding_status `grounded` with the recipe's
|
||||||
transaction once its last round is done. Each is stored as a dinner
|
recipe_id, photo_id, method, minutes_total and difficulty and the
|
||||||
with status `suggested`, origin `side_quest`, grounding_status
|
model's cuisine, and a `pending` suggestion with the name it is
|
||||||
`grounded` with the recipe's recipe_id, photo_id, method,
|
suggested under, the reason and `cited_regular_ids` — unless, since the
|
||||||
minutes_total and difficulty and the model's cuisine, and a `pending`
|
job read its input, a dinner it repeats was added, a suggestion it
|
||||||
suggestion with the name it is suggested under, the reason and
|
repeats passed, or a Regular it cites renamed or archived (checked in
|
||||||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
the storing transaction). The job publishes
|
||||||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||||
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
|
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
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
|
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
`failed`; Side quests stored before the failure stay in the deck. If
|
||||||
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
|
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
|
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
|
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||||
@@ -484,13 +480,15 @@ automatic** (D-021).
|
|||||||
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
`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 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`.
|
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||||
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
- Rotation check (`plan.Service`, D-021). This release provides the
|
||||||
calls `MarkTried` for the household-local day just past (see Scheduler).
|
`MarkTried` service method and the rotation review read and decide APIs
|
||||||
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
below. The `rotation_check` schedule that is to call `MarkTried` for the
|
||||||
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
household-local day just past is planned (Feature 20, the scheduler) and
|
||||||
emits its `rotation_review`, in one transaction;
|
not wired yet: nothing in production calls `MarkTried`, so no Rotation
|
||||||
a dinner already checked for that date is left as it is and not
|
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||||
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
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
|
||||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||||
@@ -535,8 +533,8 @@ automatic** (D-021).
|
|||||||
received.
|
received.
|
||||||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||||||
clearing the `suggestion_batches` and `week_plans` references first. The
|
clearing the `suggestion_batches` and `week_plans` references first. The
|
||||||
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
harness does not call it: the scheduler's daily `ai_runs` purge
|
||||||
its fire time less `RunRetention`.
|
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
|
||||||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||||||
takes a scope key, and concurrent runs of one skill with one scope share a
|
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
|
single in-flight run and its result, error included (single-flight); runs
|
||||||
@@ -651,8 +649,7 @@ 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
|
`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
|
waits for it, then finds the list titled (that title stands) or frozen
|
||||||
untitled (the job fails `title_failed`), and stores nothing. The
|
untitled (the job fails `title_failed`), and stores nothing. The
|
||||||
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
`week_rollover` schedule is to call `Freeze` for last week.
|
||||||
`Freeze` for last week, judging the week ended by its fire time.
|
|
||||||
|
|
||||||
## Recipe grounding
|
## Recipe grounding
|
||||||
|
|
||||||
@@ -778,92 +775,16 @@ purge are fixed and not configurable.
|
|||||||
|
|
||||||
| Schedule | When | Does |
|
| Schedule | When | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
|
||||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
|
||||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||||
| `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`). |
|
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
|
||||||
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
|
| `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 (`shopping.Service.RolloverSchedule`). |
|
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||||
|
|
||||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
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
|
## Notifications
|
||||||
|
|
||||||
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
||||||
@@ -872,43 +793,25 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
- 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
|
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||||
change and its notification commit together or not at all:
|
change and its notification commit together or not at all:
|
||||||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
|
||||||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
AI's draft of a week. Only the first draft of a week notifies: a
|
||||||
week. Only the first draft of a week notifies: a
|
|
||||||
redraft (Rebuild week) has the same key, and a manual swap never
|
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",
|
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
|
or "The plan for the week of Oct 26") and its body the drafted
|
||||||
dinners Monday to Sunday.
|
dinners Monday to Sunday.
|
||||||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||||
after Push to Calendar; remaking it does not notify.
|
after Push to Calendar; remaking it does not notify.
|
||||||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
||||||
was scheduled for (carried in its job, so a batch caught up late is
|
`manual` suggestion batch first adds Side quests to the deck, in the
|
||||||
still keyed by its own week), or `new_suggestions:{batchId}` for a
|
transaction storing them, so they are announced even when a later
|
||||||
`manual` one (URL `/discover`), once, when the batch's last round is
|
round or recording the batch's end fails; it names that round's Side
|
||||||
done, in the one transaction that stores all its Side quests, so they
|
quests, and a later round's emit stores nothing. A `replenish` batch
|
||||||
and their notification commit together or not at all. Its title
|
never notifies.
|
||||||
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
|
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||||
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
||||||
naming the dinner planned on the date; it emits nothing for a week
|
on the date and emits nothing for a week with no plan. The scheduler
|
||||||
with no plan. The `day_of_reminder` schedule calls it at
|
calls it at `reminder_time` (Feature 20).
|
||||||
`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`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
webhook.
|
||||||
@@ -1165,7 +1068,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
request. 404 when the week has no Supply run, 409
|
request. 404 when the week has no Supply run, 409
|
||||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||||
`shopping_chat_autosend` calls, through `Autosend`.
|
`shopping_chat_autosend` calls (Feature 20).
|
||||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
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
|
needed, needed_count, total_count}` with the list's counts after the
|
||||||
@@ -1195,7 +1098,6 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dateLabel": "Friday, Oct 2",
|
"dateLabel": "Friday, Oct 2",
|
||||||
"timezone": "America/Chicago",
|
|
||||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||||
"sideQuestCount": 10,
|
"sideQuestCount": 10,
|
||||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||||
@@ -1205,11 +1107,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
|
|
||||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
`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
|
when the week has no plan; it changes at the household's local
|
||||||
midnight, so the PWA reads Home again at the next local midnight of
|
midnight. `photo` and `minutes_total` are null when unknown.
|
||||||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
|
||||||
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
|
`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
|
the current week's (`shopping.Get`); with neither, it is the current
|
||||||
week with `ready` false. `recentUpdates.unread` is true when a
|
week with `ready` false. `recentUpdates.unread` is true when a
|
||||||
@@ -1224,7 +1123,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
one update):
|
one update):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Every field comes from the stored payload, which never holds a webhook
|
Every field comes from the stored payload, which never holds a webhook
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const (
|
|||||||
StatusProviderError RunStatus = "provider_error"
|
StatusProviderError RunStatus = "provider_error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunRetention is how long ai_runs rows are kept. The daily purge,
|
// RunRetention is how long ai_runs rows are kept. The daily purge calls
|
||||||
// PurgeSchedule, calls Purge with its fire time less RunRetention.
|
// Purge with time.Now().Add(-RunRetention).
|
||||||
const RunRetention = 30 * 24 * time.Hour
|
const RunRetention = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
||||||
@@ -46,6 +46,9 @@ var runSaveTimeout = 5 * time.Second
|
|||||||
// Purge deletes the ai_runs rows created before olderThan and returns how
|
// Purge deletes the ai_runs rows created before olderThan and returns how
|
||||||
// many it deleted. The suggestion_batches and week_plans references to
|
// many it deleted. The suggestion_batches and week_plans references to
|
||||||
// those rows are cleared first, in the same transaction.
|
// 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) {
|
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
||||||
if olderThan.IsZero() {
|
if olderThan.IsZero() {
|
||||||
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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),
|
ListReady(week, 19, 7),
|
||||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||||
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
|
RotationReview("01J9DINNER", "Pho", week),
|
||||||
}
|
}
|
||||||
for _, ev := range events {
|
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 {
|
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"])
|
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cards' "Open" buttons link under the public URL to the event's week.
|
// The cards' "Open" buttons link under the public URL.
|
||||||
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"} {
|
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||||
var cards []cardV2
|
var cards []cardV2
|
||||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||||
|
|||||||
+18
-40
@@ -2,7 +2,6 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
|||||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||||
Title: planTitle(week, thisWeek),
|
Title: planTitle(week, thisWeek),
|
||||||
Body: nameList(dinners),
|
Body: nameList(dinners),
|
||||||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
URL: "/plan",
|
||||||
Week: week,
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,65 +59,48 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
|||||||
Key: string(notify.ListReady) + ":" + week.String(),
|
Key: string(notify.ListReady) + ":" + week.String(),
|
||||||
Title: "This week's Supply run is ready",
|
Title: "This week's Supply run is ready",
|
||||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
URL: "/plan/shopping",
|
||||||
Week: week,
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||||
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
// name, planned on date.
|
||||||
// own call to action.
|
|
||||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||||
return notify.Event{
|
return notify.Event{
|
||||||
Type: notify.DayOfReminder,
|
Type: notify.DayOfReminder,
|
||||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||||
Title: "Tonight: " + name + " · Recipe →",
|
Title: "Tonight's dinner",
|
||||||
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,
|
Body: name,
|
||||||
URL: "/dinner/" + dinnerID,
|
URL: "/dinner/" + dinnerID,
|
||||||
Week: household.WeekStart(date),
|
Week: household.WeekStart(date),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
|
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||||
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
|
// made in the week starting on week, naming its Side quests.
|
||||||
// one, of the week starting on week: how many new Side quests there are,
|
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||||
// and their names.
|
|
||||||
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
|
|
||||||
return notify.Event{
|
return notify.Event{
|
||||||
Type: notify.NewSuggestions,
|
Type: notify.NewSuggestions,
|
||||||
Key: string(notify.NewSuggestions) + ":" + id,
|
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||||
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
|
Title: "New Side quests are in",
|
||||||
Body: nameList(names),
|
Body: nameList(names),
|
||||||
URL: "/discover",
|
URL: "/discover",
|
||||||
Week: week,
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotationReview is the rotation_review event, keyed
|
// RotationReview is the rotation_review event asking whether the dinner
|
||||||
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||||
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
// Regular.
|
||||||
// in rotation?". It opens Home, where the Rotation check card asks.
|
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||||
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
|
||||||
return notify.Event{
|
return notify.Event{
|
||||||
Type: notify.RotationReview,
|
Type: notify.RotationReview,
|
||||||
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||||
Title: "How was " + name + "? Keep it in rotation?",
|
Title: "Keep it in rotation?",
|
||||||
|
Body: "You tried " + name + ". Make it a Regular?",
|
||||||
URL: "/",
|
URL: "/",
|
||||||
Week: household.WeekStart(planDate),
|
Week: week,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,13 +171,9 @@ func planTitle(week, thisWeek store.LocalDate) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Text is the Google Chat message of p: its title, its body after a colon
|
// 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. A title ending in an arrow,
|
// and the event's call to action with an arrow.
|
||||||
// with no body, carries its own call to action and is the message as it is.
|
|
||||||
func Text(p notify.Payload) string {
|
func Text(p notify.Payload) string {
|
||||||
text := p.Title
|
text := p.Title
|
||||||
if p.Body == "" && strings.HasSuffix(text, "→") {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
if p.Body != "" {
|
if p.Body != "" {
|
||||||
if endsSentence(text) {
|
if endsSentence(text) {
|
||||||
text += " "
|
text += " "
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -39,7 +38,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready",
|
name: "plan ready",
|
||||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan?week=2026-10-12",
|
url: "/plan",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's plan is ready",
|
title: "This week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||||
@@ -49,7 +48,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready with three dinners",
|
name: "plan ready with three dinners",
|
||||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan?week=2026-10-12",
|
url: "/plan",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's plan is ready",
|
title: "This week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala",
|
body: "Tacos, Bolognese, Tikka masala",
|
||||||
@@ -59,7 +58,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready for next week",
|
name: "plan ready for next week",
|
||||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||||
key: "plan_ready:2026-10-12",
|
key: "plan_ready:2026-10-12",
|
||||||
url: "/plan?week=2026-10-12",
|
url: "/plan",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "Next week's plan is ready",
|
title: "Next week's plan is ready",
|
||||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||||
@@ -69,7 +68,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "plan ready for a later week",
|
name: "plan ready for a later week",
|
||||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||||
key: "plan_ready:2026-10-26",
|
key: "plan_ready:2026-10-26",
|
||||||
url: "/plan?week=2026-10-26",
|
url: "/plan",
|
||||||
week: "2026-10-26",
|
week: "2026-10-26",
|
||||||
title: "The plan for the week of Oct 26 is ready",
|
title: "The plan for the week of Oct 26 is ready",
|
||||||
body: "Tacos",
|
body: "Tacos",
|
||||||
@@ -79,7 +78,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "list ready",
|
name: "list ready",
|
||||||
ev: ListReady(week, 19, 7),
|
ev: ListReady(week, 19, 7),
|
||||||
key: "list_ready:2026-10-12",
|
key: "list_ready:2026-10-12",
|
||||||
url: "/plan/shopping?week=2026-10-12",
|
url: "/plan/shopping",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's Supply run is ready",
|
title: "This week's Supply run is ready",
|
||||||
body: "19 items to buy for 7 dinners",
|
body: "19 items to buy for 7 dinners",
|
||||||
@@ -89,7 +88,7 @@ func TestMessages(t *testing.T) {
|
|||||||
name: "list ready singular",
|
name: "list ready singular",
|
||||||
ev: ListReady(week, 1, 1),
|
ev: ListReady(week, 1, 1),
|
||||||
key: "list_ready:2026-10-12",
|
key: "list_ready:2026-10-12",
|
||||||
url: "/plan/shopping?week=2026-10-12",
|
url: "/plan/shopping",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "This week's Supply run is ready",
|
title: "This week's Supply run is ready",
|
||||||
body: "1 item to buy for 1 dinner",
|
body: "1 item to buy for 1 dinner",
|
||||||
@@ -101,19 +100,9 @@ func TestMessages(t *testing.T) {
|
|||||||
key: "day_of_reminder:2026-10-15",
|
key: "day_of_reminder:2026-10-15",
|
||||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "Tonight: Tacos · Recipe →",
|
title: "Tonight's dinner",
|
||||||
body: "",
|
body: "Tacos",
|
||||||
chatT: "Tonight: Tacos · Recipe →",
|
chatT: "Tonight's dinner: Tacos. Open the 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",
|
name: "new suggestions",
|
||||||
@@ -121,29 +110,19 @@ func TestMessages(t *testing.T) {
|
|||||||
key: "new_suggestions:01J9BATCH",
|
key: "new_suggestions:01J9BATCH",
|
||||||
url: "/discover",
|
url: "/discover",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "5 new Side quests are in",
|
title: "New Side quests are in",
|
||||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||||
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
chatT: "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",
|
name: "rotation review",
|
||||||
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
|
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||||
key: "rotation_review:01J9DINNER:2026-10-14",
|
key: "rotation_review:01J9DINNER",
|
||||||
url: "/",
|
url: "/",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "How was Lemon orzo? Keep it in rotation?",
|
title: "Keep it in rotation?",
|
||||||
body: "",
|
body: "You tried Lemon orzo. Make it a Regular?",
|
||||||
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
|
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
ev := tc.ev
|
ev := tc.ev
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ type Querier interface {
|
|||||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
// when no job is queued.
|
// when no job is queued.
|
||||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
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.
|
// Clears every day's calendar event id: none is on the chosen calendar.
|
||||||
ClearCalendarEvents(ctx context.Context, updatedAt string) error
|
ClearCalendarEvents(ctx context.Context, updatedAt string) error
|
||||||
// Records that Google refuses the stored grant: clears the refresh token and
|
// Records that Google refuses the stored grant: clears the refresh token and
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -138,9 +138,6 @@ type Service struct {
|
|||||||
grounder *recipes.Grounder
|
grounder *recipes.Grounder
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
groundJob *jobs.Kind[GroundJob]
|
groundJob *jobs.Kind[GroundJob]
|
||||||
// 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,
|
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
||||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||||
@@ -160,11 +157,10 @@ type Service struct {
|
|||||||
offerSearches map[string]map[*offerSearch]struct{}
|
offerSearches map[string]map[*offerSearch]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a service over st that grounds dinners with grounder,
|
// NewService returns a service over st that grounds dinners with grounder
|
||||||
// calls wake (the scheduler's Wake) after every committed change to a
|
// and registers the KindGroundDinner job on runner, which must not have
|
||||||
// dinner's method or minutes, and registers the KindGroundDinner job on
|
// started yet.
|
||||||
// runner, which must not have started yet.
|
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
|
|
||||||
switch {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("dinners: nil store")
|
return nil, errors.New("dinners: nil store")
|
||||||
@@ -172,12 +168,10 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
|||||||
return nil, errors.New("dinners: nil grounder")
|
return nil, errors.New("dinners: nil grounder")
|
||||||
case runner == nil:
|
case runner == nil:
|
||||||
return nil, errors.New("dinners: nil job runner")
|
return nil, errors.New("dinners: nil job runner")
|
||||||
case wake == nil:
|
|
||||||
return nil, errors.New("dinners: nil wake")
|
|
||||||
case logger == nil:
|
case logger == nil:
|
||||||
return nil, errors.New("dinners: nil logger")
|
return nil, errors.New("dinners: nil logger")
|
||||||
}
|
}
|
||||||
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{})}
|
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{})}
|
||||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -508,9 +502,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, err
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
if p.Method != nil || p.MinutesTotal != nil {
|
|
||||||
s.wake()
|
|
||||||
}
|
|
||||||
if renamed {
|
if renamed {
|
||||||
updateCommitted()
|
updateCommitted()
|
||||||
s.clearOffers(row.ID, row.GroundingRevision)
|
s.clearOffers(row.ID, row.GroundingRevision)
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return s.currentGrounding(ctx, row.ID)
|
return s.currentGrounding(ctx, row.ID)
|
||||||
}
|
}
|
||||||
s.wake()
|
|
||||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +577,6 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||||
}
|
}
|
||||||
s.wake()
|
|
||||||
s.clearOffers(cur.ID, math.MaxInt64)
|
s.clearOffers(cur.ID, math.MaxInt64)
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
@@ -702,7 +700,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||||
}
|
}
|
||||||
s.wake()
|
|
||||||
s.clearOffers(row.ID, math.MaxInt64)
|
s.clearOffers(row.ID, math.MaxInt64)
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ func NewService(settings *household.SettingsService, plans *plan.Service, sugges
|
|||||||
type Home struct {
|
type Home struct {
|
||||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||||
DateLabel string `json:"dateLabel"`
|
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;
|
// Tonight is the dinner planned for today on the current week's plan;
|
||||||
// nil when the week has no plan.
|
// nil when the week has no plan.
|
||||||
Tonight *Dinner `json:"tonight"`
|
Tonight *Dinner `json:"tonight"`
|
||||||
@@ -95,8 +92,7 @@ type RecentUpdates struct {
|
|||||||
Unread bool `json:"unread"`
|
Unread bool `json:"unread"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get reads the Home screen at the service's "now". It only reads: it
|
// Get reads the Home screen at the service's "now".
|
||||||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
|
||||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||||
settings, err := s.settings.Get(ctx)
|
settings, err := s.settings.Get(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,7 +110,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Home{}, err
|
return Home{}, err
|
||||||
}
|
}
|
||||||
sideQuests, err := s.suggest.PendingCount(ctx)
|
deck, err := s.suggest.Deck(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||||
}
|
}
|
||||||
@@ -128,9 +124,8 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
|
|||||||
}
|
}
|
||||||
return Home{
|
return Home{
|
||||||
DateLabel: now.Format("Monday, Jan 2"),
|
DateLabel: now.Format("Monday, Jan 2"),
|
||||||
Timezone: settings.Timezone,
|
|
||||||
Tonight: tonight,
|
Tonight: tonight,
|
||||||
SideQuestCount: sideQuests,
|
SideQuestCount: len(deck.Suggestions),
|
||||||
ShoppingList: list,
|
ShoppingList: list,
|
||||||
RecentUpdates: RecentUpdates{Unread: unread},
|
RecentUpdates: RecentUpdates{Unread: unread},
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
|
|||||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no model in this test")
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
}
|
}
|
||||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,6 @@ func TestHomeEmpty(t *testing.T) {
|
|||||||
h := e.at(t, "2026-10-13 23:59")
|
h := e.at(t, "2026-10-13 23:59")
|
||||||
want := home.Home{
|
want := home.Home{
|
||||||
DateLabel: "Tuesday, Oct 13",
|
DateLabel: "Tuesday, Oct 13",
|
||||||
Timezone: zone,
|
|
||||||
ShoppingList: home.ShoppingStatus{
|
ShoppingList: home.ShoppingStatus{
|
||||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||||
},
|
},
|
||||||
@@ -290,32 +289,6 @@ 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
|
// 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.
|
// current week's; the weeks are the household's local weeks.
|
||||||
func TestShoppingStatus(t *testing.T) {
|
func TestShoppingStatus(t *testing.T) {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -294,19 +293,6 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
|
|||||||
return nil
|
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.
|
// enabledChannels returns the channels pref switches on.
|
||||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||||
var chs []Channel
|
var chs []Channel
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
s.notifier.Wake()
|
s.notifier.Wake()
|
||||||
s.wake()
|
|
||||||
return planID, nil
|
return planID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-46
@@ -141,9 +141,6 @@ type Service struct {
|
|||||||
draft Drafter
|
draft Drafter
|
||||||
cal Calendar
|
cal Calendar
|
||||||
notifier *notify.Service
|
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
|
logger *slog.Logger
|
||||||
draftJob *jobs.Kind[DraftJob]
|
draftJob *jobs.Kind[DraftJob]
|
||||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||||
@@ -153,12 +150,11 @@ type Service struct {
|
|||||||
|
|
||||||
// NewService returns a service over st that drafts with draft, pushes to
|
// NewService returns a service over st that drafts with draft, pushes to
|
||||||
// cal with links under publicURL, tells the household through notifier,
|
// cal with links under publicURL, tells the household through notifier,
|
||||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
// which must be over st, and registers the KindDraftWeek job on
|
||||||
// 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:
|
// runner, which must not have started yet. The kind is service-only:
|
||||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||||
// week, are the only way to enqueue it.
|
// 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) {
|
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||||
switch {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("plan: nil store")
|
return nil, errors.New("plan: nil store")
|
||||||
@@ -170,14 +166,12 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
|||||||
return nil, errors.New("plan: nil calendar")
|
return nil, errors.New("plan: nil calendar")
|
||||||
case notifier == nil:
|
case notifier == nil:
|
||||||
return nil, errors.New("plan: nil notifier")
|
return nil, errors.New("plan: nil notifier")
|
||||||
case wake == nil:
|
|
||||||
return nil, errors.New("plan: nil wake")
|
|
||||||
case publicURL == nil:
|
case publicURL == nil:
|
||||||
return nil, errors.New("plan: nil public URL")
|
return nil, errors.New("plan: nil public URL")
|
||||||
case logger == nil:
|
case logger == nil:
|
||||||
return nil, errors.New("plan: nil logger")
|
return nil, errors.New("plan: nil logger")
|
||||||
}
|
}
|
||||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
|
||||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -239,10 +233,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,
|
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
||||||
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
|
// naming the dinner planned on it, and reports whether it did: a date whose
|
||||||
// whether it did: a date whose week has no plan emits nothing. The
|
// week has no plan emits nothing. The scheduler calls it at the
|
||||||
// day_of_reminder schedule calls it at the household's reminder_time
|
// household's reminder_time (Feature 20); emitting a date again stores no
|
||||||
// (ReminderSchedules); emitting a date again stores no second notification.
|
// second notification.
|
||||||
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||||
p, err := s.Get(ctx, household.WeekStart(date))
|
p, err := s.Get(ctx, household.WeekStart(date))
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -251,14 +245,15 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
d, err := p.day(date)
|
for _, d := range p.Days {
|
||||||
if err != nil {
|
if d.Date == date.String() {
|
||||||
return false, err
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||||
return false, fmt.Errorf("plan: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPlan reads the plan of the week starting weekStart through q, with
|
// readPlan reads the plan of the week starting weekStart through q, with
|
||||||
@@ -347,27 +342,10 @@ type Draft struct {
|
|||||||
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
||||||
// else it fails with ErrConfirmRequired and enqueues nothing.
|
// else it fails with ErrConfirmRequired and enqueues nothing.
|
||||||
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
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 {
|
if _, err := household.WeekDays(weekStart); err != nil {
|
||||||
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
|
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||||
}
|
}
|
||||||
var d Draft
|
var d Draft
|
||||||
planned := false
|
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
@@ -375,9 +353,6 @@ func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, con
|
|||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
case onlyUnplanned:
|
|
||||||
planned = true
|
|
||||||
return nil
|
|
||||||
case cur.Status != StatusDraft && !confirm:
|
case cur.Status != StatusDraft && !confirm:
|
||||||
return ErrConfirmRequired
|
return ErrConfirmRequired
|
||||||
}
|
}
|
||||||
@@ -400,12 +375,12 @@ func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, con
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Draft{}, false, err
|
return Draft{}, err
|
||||||
}
|
}
|
||||||
if d.Created {
|
if d.Created {
|
||||||
s.draftJob.Wake()
|
s.draftJob.Wake()
|
||||||
}
|
}
|
||||||
return d, planned, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||||
@@ -475,7 +450,6 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, err
|
return Plan{}, err
|
||||||
}
|
}
|
||||||
s.wake()
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
p := r.Payload
|
p := r.Payload
|
||||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
|
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
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?week=2026-10-12", r.Channel, r, want.Title)
|
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", 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)
|
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
||||||
}
|
}
|
||||||
r := rows[0]
|
r := rows[0]
|
||||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" ||
|
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
|
||||||
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||||
t.Errorf("day_of_reminder row = %+v", r)
|
t.Errorf("day_of_reminder row = %+v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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,11 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,40 +79,12 @@ type Decided struct {
|
|||||||
UpdatedBy string `json:"updated_by"`
|
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
|
// 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, opens
|
// household-local day just past, on a plan of any status, to tried and
|
||||||
// a pending Rotation check of it for date and emits its rotation_review
|
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||||
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
// dinner already checked for date is left as it is, so a later run never
|
||||||
// dinner already checked for date is left as it is and not notified of
|
// moves one returned to the Quest log ("We didn't make it") again. It
|
||||||
// again, so a later run never moves one returned to the Quest log ("We
|
// returns how many dinners it moved.
|
||||||
// didn't make it") again. It returns how many dinners it moved.
|
|
||||||
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -126,10 +95,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
}
|
}
|
||||||
now := store.Now().String()
|
now := store.Now().String()
|
||||||
for _, id := range ids {
|
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})
|
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||||
@@ -142,9 +107,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
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)
|
n = len(ids)
|
||||||
return nil
|
return nil
|
||||||
@@ -152,9 +114,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if n > 0 {
|
|
||||||
s.notifier.Wake()
|
|
||||||
}
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -1,805 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ func TestGetHome(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||||
"timezone": loc.String(),
|
|
||||||
"tonight": nil,
|
"tonight": nil,
|
||||||
"sideQuestCount": 0,
|
"sideQuestCount": 0,
|
||||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
"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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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")
|
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||||
return skills.Week{}, context.Canceled
|
return skills.Week{}, context.Canceled
|
||||||
}
|
}
|
||||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
notifier := notifytest.New(t, st)
|
notifier := notifytest.New(t, st)
|
||||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func newTestDinners(t *testing.T) *dinners.Service {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no model in this test")
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
}
|
}
|
||||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"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/household"
|
||||||
@@ -40,17 +39,11 @@ const (
|
|||||||
// only when no job is running it. A job that runs after the freeze finds
|
// only when no job is running it. A job that runs after the freeze finds
|
||||||
// the list titled, or frozen untitled, and stores nothing.
|
// the list titled, or frozen untitled, and stores nothing.
|
||||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
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 {
|
if _, err := household.WeekDays(weekStart); err != nil {
|
||||||
return fmt.Errorf("shopping: freeze: %w", err)
|
return fmt.Errorf("shopping: freeze: %w", err)
|
||||||
}
|
}
|
||||||
q := db.New(s.st.Writer)
|
q := db.New(s.st.Writer)
|
||||||
thisWeek, err := weekAt(ctx, q, now)
|
thisWeek, err := s.thisWeek(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -80,9 +73,9 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||||
stamp := store.Now().String()
|
now := store.Now().String()
|
||||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||||
}
|
}
|
||||||
@@ -110,12 +103,6 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
|
|||||||
// thisWeek returns the Monday of the household's current week, in its
|
// thisWeek returns the Monday of the household's current week, in its
|
||||||
// time zone, by the service's clock.
|
// time zone, by the service's clock.
|
||||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
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)
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||||
@@ -124,7 +111,7 @@ func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||||
}
|
}
|
||||||
return household.WeekStart(household.Today(t, zone)), nil
|
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Period is one heading of the Supply archive and its posts, newest first.
|
// 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 {
|
for _, r := range rows {
|
||||||
p := r.Payload
|
p := r.Payload
|
||||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
|
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
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?week=2026-10-12", r.Channel, r)
|
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
dsvc, err := dinners.NewService(e.st, grounder, runner, func() {}, logger)
|
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-97
@@ -22,9 +22,6 @@ import (
|
|||||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||||
type BatchJob struct {
|
type BatchJob struct {
|
||||||
BatchID string `json:"batch_id"`
|
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.
|
// Batch stages, as the job's result reports them.
|
||||||
@@ -53,22 +50,19 @@ 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
|
// 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
|
// no recipe grounds is dropped; any other grounding failure fails the
|
||||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||||
// instead. The batch ends done or failed. When recording the batch's end
|
// instead. Each round's grounded Side quests are stored before the next
|
||||||
// fails, so does the job; when it times out at shutdown, the job is left
|
// round starts, so the deck fills as the batch goes. The batch ends done
|
||||||
// running and the batch with it, for the next start to fail.
|
// 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.
|
||||||
//
|
//
|
||||||
// When a batch stores its Side quests depends on whether it notifies (see
|
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||||
// noticeOf). A scheduled or manual batch notifies: it holds every round's
|
// in the transaction that first adds Side quests to the deck, so the
|
||||||
// grounded Side quests and, once the last round is done, stores them all
|
// household is told of every Side quest stored, whether or not the batch
|
||||||
// in one transaction with one new_suggestions counting and naming every
|
// then ends done: a later round, or recording the batch's end, may fail.
|
||||||
// Side quest stored. So the household is told of exactly what the batch
|
// A replenish batch, which tops up the deck as the household swipes, never
|
||||||
// added, once; and a batch that fails at any round, including a later
|
// notifies. See store.
|
||||||
// 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) {
|
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)
|
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,7 +78,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||||
}
|
}
|
||||||
res, err := s.fill(ctx, batch.Trigger, p, publish)
|
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
@@ -104,12 +98,10 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
|||||||
return batchResult{}, errors.Join(err, finishErr)
|
return batchResult{}, errors.Join(err, finishErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fill does runBatch's work for job of a batch with trigger, returning a
|
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||||
// *jobs.Failure for a failure the household may see. A batch with a notice
|
// household may see. When notifies is true, store emits new_suggestions
|
||||||
// is stored once, when its last round is done; any other batch round by
|
// with the Side quests it adds.
|
||||||
// round.
|
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||||
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{}}
|
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||||
if err := publish(res); err != nil {
|
if err := publish(res); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
@@ -118,10 +110,6 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
note, err := noticeOf(trigger, job, in.Context.Today)
|
|
||||||
if err != nil {
|
|
||||||
return batchResult{}, err
|
|
||||||
}
|
|
||||||
out, err := s.suggest(ctx, batchID, in)
|
out, err := s.suggest(ctx, batchID, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -134,81 +122,34 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
|||||||
if err := publish(res); err != nil {
|
if err := publish(res); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
|
var week *store.LocalDate
|
||||||
|
if notifies {
|
||||||
|
w := household.WeekStart(in.Context.Today)
|
||||||
|
week = &w
|
||||||
|
}
|
||||||
left := out.Suggestions
|
left := out.Suggestions
|
||||||
// held are a notifying batch's grounded Side quests, not yet stored.
|
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||||
var held []groundedQuest
|
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||||
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):]
|
left = left[len(round):]
|
||||||
grounded, err := s.ground(ctx, round)
|
grounded, err := s.ground(ctx, round)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
res.Dropped += len(round) - len(grounded)
|
res.Dropped += len(round) - len(grounded)
|
||||||
if note != nil {
|
ids, err := s.store(ctx, batchID, week, grounded)
|
||||||
held = append(held, grounded...)
|
if err != nil {
|
||||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
|
res.Dropped += len(grounded) - len(ids)
|
||||||
|
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||||
if err := publish(res); err != nil {
|
if err := publish(res); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if note != nil {
|
|
||||||
if err := s.storeInto(ctx, &res, note, held); err != nil {
|
|
||||||
return batchResult{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Stage = StageDone
|
res.Stage = StageDone
|
||||||
return res, nil
|
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,
|
// 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
|
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||||
// fails with ReasonNoRegulars.
|
// fails with ReasonNoRegulars.
|
||||||
@@ -283,14 +224,13 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
|||||||
// or archived. Passed names are read in the same transaction as the store,
|
// or archived. Passed names are read in the same transaction as the store,
|
||||||
// so a Pass or drop committed before it is never missed.
|
// so a Pass or drop committed before it is never missed.
|
||||||
//
|
//
|
||||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||||
// note, counting and naming those stored, is emitted in the same
|
// naming those stored is emitted in the same transaction, for the week
|
||||||
// transaction, so the Side quests and their notification commit together
|
// starting week, so the Side quests and their notification commit together
|
||||||
// or not at all: a notification that cannot be stored fails the store, and
|
// or not at all: a notification that cannot be stored fails the store, and
|
||||||
// the batch with it. A notifying batch calls store once, with all its
|
// the batch with it. The event's key makes the first round to store Side
|
||||||
// Side quests, so the count is the batch's (see runBatch). This is the
|
// quests the one that notifies; a later round's emit stores nothing.
|
||||||
// only place new_suggestions is emitted.
|
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||||
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
|
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
var stored []string
|
var stored []string
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -358,15 +298,15 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
|||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
stored = append(stored, d.Name)
|
stored = append(stored, d.Name)
|
||||||
}
|
}
|
||||||
if note == nil || len(stored) == 0 {
|
if week == nil || len(stored) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
|
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if note != nil && len(stored) > 0 {
|
if week != nil && len(stored) > 0 {
|
||||||
s.notifier.Wake()
|
s.notifier.Wake()
|
||||||
}
|
}
|
||||||
return ids, nil
|
return ids, nil
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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,7 +19,6 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
@@ -155,35 +154,17 @@ type Batch struct {
|
|||||||
Created bool `json:"-"`
|
Created bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartBatch creates a manual or replenish batch and enqueues the
|
// StartBatch creates a batch with trigger and enqueues the
|
||||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||||
// or running, which it returns instead with Created false. A scheduled
|
// or running, which it returns instead with Created false.
|
||||||
// batch is started by StartScheduledBatch.
|
|
||||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
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) {
|
if !slices.Contains(triggers, trigger) {
|
||||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", 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
|
var b Batch
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
var err error
|
var err error
|
||||||
b, err = s.startBatch(ctx, tx, trigger, week)
|
b, err = s.startBatch(ctx, tx, trigger)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,11 +228,10 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
|||||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startBatch does StartBatch's work in tx, for a batch with trigger whose
|
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||||
// job has week (BatchJob.Week): the batch and the job that runs it are
|
// it are committed together, so a batch is never seen without its job. The
|
||||||
// committed together, so a batch is never seen without its job. The caller
|
// caller wakes the job runner once tx has committed and Created is true.
|
||||||
// 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) {
|
||||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
|
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
cur, err := s.activeBatch(ctx, q)
|
cur, err := s.activeBatch(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -270,7 +250,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||||
}
|
}
|
||||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
|
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||||
}
|
}
|
||||||
@@ -366,17 +346,6 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
|||||||
return deck, nil
|
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
|
// 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
|
// batch's job when the decision called for one: the batch it started, or
|
||||||
// the one already pending or running that it joined.
|
// the one already pending or running that it joined.
|
||||||
@@ -459,7 +428,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
|||||||
if left >= ReplenishBelow {
|
if left >= ReplenishBelow {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
|
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,45 +300,6 @@ func (e *env) runBatch(t *testing.T) batchResult {
|
|||||||
return res
|
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 {
|
func (e *env) batchStatus(t *testing.T, id string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var status string
|
var status string
|
||||||
@@ -443,9 +404,8 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||||
// quests stores new_suggestions counting and naming them, keyed by the
|
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||||
// scheduled ISO week or the manual batch, for that week or the current
|
// replenish batch, or one that adds none, stores nothing.
|
||||||
// one; a replenish batch, or one that adds none, stores nothing.
|
|
||||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
trigger string
|
trigger string
|
||||||
@@ -468,7 +428,10 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
|||||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||||
)
|
)
|
||||||
b, key := e.startBatch(t, tc.trigger)
|
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||||
t.Fatalf("batch job %s", job.Status)
|
t.Fatalf("batch job %s", job.Status)
|
||||||
}
|
}
|
||||||
@@ -483,70 +446,64 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
|||||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||||
}
|
}
|
||||||
r := rows[0]
|
r := rows[0]
|
||||||
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
|
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||||
if tc.trigger == suggest.TriggerScheduled {
|
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||||
week = scheduledWeek.String()
|
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
|
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
|
||||||
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
|
// the first round to add Side quests, so a batch whose later round fails
|
||||||
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
|
// still tells the household of those in the deck, once; a later round that
|
||||||
// F.
|
// succeeds adds no second notification; a replenish batch notifies nothing.
|
||||||
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
|
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||||
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 {
|
for _, tc := range []struct {
|
||||||
trigger string
|
trigger string
|
||||||
notify bool
|
lastFails bool
|
||||||
|
notify bool
|
||||||
}{
|
}{
|
||||||
{suggest.TriggerScheduled, true},
|
{suggest.TriggerScheduled, true, true},
|
||||||
{suggest.TriggerManual, true},
|
{suggest.TriggerManual, true, true},
|
||||||
{suggest.TriggerReplenish, false},
|
{suggest.TriggerManual, false, true},
|
||||||
|
{suggest.TriggerReplenish, true, false},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.trigger, func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.start(t)
|
e.start(t)
|
||||||
e.twoRoundBatch(t, true)
|
// The first round grounds BatchSize Side quests but B; the
|
||||||
|
// second round grounds F.
|
||||||
b, key := e.startBatch(t, tc.trigger)
|
var quests []string
|
||||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
for i := range suggest.BatchSize + 1 {
|
||||||
t.Fatalf("batch job %s, want succeeded", job.Status)
|
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"))
|
||||||
}
|
}
|
||||||
deck := e.deckNames(t)
|
e.reply(quests...)
|
||||||
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
|
if tc.lastFails {
|
||||||
t.Fatalf("deck = %v, want %v", deck, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||||
if !tc.notify {
|
if !tc.notify {
|
||||||
@@ -555,47 +512,9 @@ func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
want := fmt.Sprintf("%d new Side quests are in", len(deck))
|
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||||
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 + 1 more" {
|
||||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
|
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1102,7 +1021,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
|
|||||||
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
||||||
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1150,14 +1069,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
|||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no draft in this test")
|
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), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1380,7 +1299,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
|||||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
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.
|
// 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.Title != "1 new Side quest is in" || 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.Body != "Chili Verde" {
|
||||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1555,47 +1474,6 @@ 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.
|
// noCalendar is a plan.Calendar for tests that never push.
|
||||||
type noCalendar struct{}
|
type noCalendar struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -88,7 +88,7 @@ describe('App routes', () => {
|
|||||||
|
|
||||||
it('/ renders the Home screen inside the shell', async () => {
|
it('/ renders the Home screen inside the shell', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||||
renderAt('/')
|
renderAt('/')
|
||||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
@@ -273,13 +273,7 @@ describe('App bootstrap', () => {
|
|||||||
|
|
||||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
|
||||||
stubFetchByPath({
|
|
||||||
...SIGNED_IN,
|
|
||||||
'/api/v1/me': () => mes.shift()!,
|
|
||||||
'/api/v1/home': () => jsonResponse(200, HOME),
|
|
||||||
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
|
|
||||||
})
|
|
||||||
renderAt('/')
|
renderAt('/')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||||
@@ -300,7 +294,7 @@ describe('App offline bootstrap', () => {
|
|||||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||||
// member, then the app is closed.
|
// member, then the app is closed.
|
||||||
async function visitOnline() {
|
async function visitOnline() {
|
||||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||||
const { unmount } = renderAt('/')
|
const { unmount } = renderAt('/')
|
||||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||||
unmount()
|
unmount()
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { HOME_PATH } from './api.ts'
|
import { HOME_PATH } from './api.ts'
|
||||||
import { HomePage } from './HomePage.tsx'
|
import { HomePage } from './HomePage.tsx'
|
||||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
|
||||||
|
|
||||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||||
// Supply run and nothing unread.
|
// Supply run and nothing unread.
|
||||||
@@ -20,7 +19,6 @@ const NOTHING_YET = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ function Where() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHome(body: unknown) {
|
function renderHome(body: unknown) {
|
||||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
|
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
|
||||||
return renderRouter()
|
return renderRouter()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,31 +71,6 @@ describe('HomePage', () => {
|
|||||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
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 () => {
|
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||||
renderHome(HOME)
|
renderHome(HOME)
|
||||||
|
|
||||||
@@ -225,11 +198,7 @@ describe('HomePage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('reports a failure with Retry, and no made-up content', async () => {
|
it('reports a failure with Retry, and no made-up content', async () => {
|
||||||
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
|
||||||
stubFetchByPath({
|
|
||||||
[HOME_PATH]: () => homes.shift()!,
|
|
||||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
|
||||||
})
|
|
||||||
renderRouter()
|
renderRouter()
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Photo } from '../ui/Photo.tsx'
|
|||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||||
import styles from './HomePage.module.css'
|
import styles from './HomePage.module.css'
|
||||||
import { RotationCheck } from './RotationCheck.tsx'
|
|
||||||
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
||||||
|
|
||||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||||
@@ -109,8 +108,7 @@ function Tiles() {
|
|||||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||||
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
|
// from GET /api/v1/home.
|
||||||
// rotation?", while a tried dinner waits for the household's answer.
|
|
||||||
//
|
//
|
||||||
// The bell carries a grape dot while there are unread updates and opens the
|
// The bell carries a grape dot while there are unread updates and opens the
|
||||||
// Recent updates sheet, which marks them seen.
|
// Recent updates sheet, which marks them seen.
|
||||||
@@ -143,7 +141,6 @@ export function HomePage() {
|
|||||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||||
)}
|
)}
|
||||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||||
<RotationCheck />
|
|
||||||
<Tiles />
|
<Tiles />
|
||||||
{home.isPending && (
|
{home.isPending && (
|
||||||
<div className={styles.statuses} aria-hidden="true">
|
<div className={styles.statuses} aria-hidden="true">
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 32px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo {
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 18px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kicker {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-neutral-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
.question {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "We didn't make it": a text link at the 44 px tap minimum. */
|
|
||||||
.notMade {
|
|
||||||
align-self: center;
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-accent-700);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notMade:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notMade:focus-visible {
|
|
||||||
outline: 3px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmation {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: var(--color-accent-2-300);
|
|
||||||
color: var(--color-accent-2-900);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
|
||||||
import { HOME } from '../test/ApiProviders.tsx'
|
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
|
||||||
import { HOME_PATH } from './api.ts'
|
|
||||||
import { HomePage } from './HomePage.tsx'
|
|
||||||
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
|
|
||||||
|
|
||||||
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
|
|
||||||
// /api/v1/rotation-reviews sends them.
|
|
||||||
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
|
|
||||||
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
|
||||||
})
|
|
||||||
|
|
||||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
|
||||||
|
|
||||||
// stubApi answers Home's reads like the server: the pending Rotation checks
|
|
||||||
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
|
|
||||||
// decisions until the test answers it; a 200 answer takes the check off the
|
|
||||||
// server's list, as the server does.
|
|
||||||
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
|
|
||||||
const server = [...reviews]
|
|
||||||
const decisions: Deferred[] = []
|
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
|
||||||
const path = String(input)
|
|
||||||
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
|
|
||||||
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
|
|
||||||
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
|
|
||||||
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
|
|
||||||
if (init?.method === 'POST' && review !== undefined) {
|
|
||||||
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
|
||||||
const body: unknown = JSON.parse(String(init.body))
|
|
||||||
return new Promise<Response>((resolve) =>
|
|
||||||
decisions.push({
|
|
||||||
path,
|
|
||||||
body,
|
|
||||||
resolve: (response) => {
|
|
||||||
if (response.ok) server.splice(server.indexOf(review), 1)
|
|
||||||
resolve(response)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
|
||||||
})
|
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
|
||||||
return { fetchMock, decisions, server }
|
|
||||||
}
|
|
||||||
|
|
||||||
// decided is the server's answer to a decision on review.
|
|
||||||
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
|
|
||||||
return jsonResponse(200, {
|
|
||||||
dinner: review.dinner,
|
|
||||||
plan_date: review.plan_date,
|
|
||||||
decision,
|
|
||||||
dinner_status: dinnerStatus,
|
|
||||||
decided_at: '2026-10-02T13:00:00Z',
|
|
||||||
updated_by: 'sub-1',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
|
|
||||||
// at path.
|
|
||||||
async function renderHome(path = '/') {
|
|
||||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
|
||||||
await api.loadMe()
|
|
||||||
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
|
|
||||||
render(
|
|
||||||
<ApiProvider client={api}>
|
|
||||||
<QueryClientProvider client={createQueryClient()}>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</ApiProvider>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
|
|
||||||
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
|
|
||||||
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
|
|
||||||
|
|
||||||
describe('RotationCheck', () => {
|
|
||||||
it('shows no card when nothing waits for an answer', async () => {
|
|
||||||
const { fetchMock } = stubApi([])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
|
|
||||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
|
|
||||||
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
const check = await card()
|
|
||||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
|
||||||
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
|
|
||||||
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
|
||||||
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
const keep = within(check).getByRole('button', { name: 'Keep it' })
|
|
||||||
const drop = within(check).getByRole('button', { name: 'Not for us' })
|
|
||||||
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
|
|
||||||
expect(keep.className).toMatch(/primary/)
|
|
||||||
expect(drop.className).toMatch(/outline/)
|
|
||||||
expect(notMade.className).toMatch(/notMade/)
|
|
||||||
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
|
|
||||||
|
|
||||||
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
|
|
||||||
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
||||||
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
||||||
|
|
||||||
// The card waits for the household: nothing is sent unasked.
|
|
||||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
|
||||||
expect(posts(fetchMock)).toHaveLength(0)
|
|
||||||
expect(await card()).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
|
|
||||||
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
|
|
||||||
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
|
|
||||||
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
|
|
||||||
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
fireEvent.click(within(await card()).getByRole('button', { name: label }))
|
|
||||||
|
|
||||||
// Applied before the server answers.
|
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
|
|
||||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
|
||||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
|
||||||
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
|
|
||||||
expect(posts(fetchMock)).toHaveLength(1)
|
|
||||||
|
|
||||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
|
|
||||||
// Rotation checks are read again once saved; none is left.
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
|
|
||||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
|
|
||||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
|
||||||
|
|
||||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
|
||||||
const next = await card()
|
|
||||||
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
|
|
||||||
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
|
|
||||||
|
|
||||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
|
||||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
|
|
||||||
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
|
|
||||||
|
|
||||||
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
|
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
|
|
||||||
await waitFor(() => expect(decisions).toHaveLength(2))
|
|
||||||
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
|
|
||||||
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
|
|
||||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
|
||||||
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('puts the check back with an ErrorBanner when the answer fails', async () => {
|
|
||||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
|
||||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
|
||||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
|
||||||
|
|
||||||
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
|
|
||||||
const check = await card()
|
|
||||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
|
||||||
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
|
|
||||||
const { decisions } = stubApi([SHAKSHUKA])
|
|
||||||
await renderHome()
|
|
||||||
|
|
||||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
|
||||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
|
||||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
|
|
||||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
|
||||||
// The server closed the check, so the list read again has none.
|
|
||||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports Rotation checks it cannot load, with Retry', async () => {
|
|
||||||
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
|
|
||||||
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(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
||||||
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'
|
|
||||||
const { notice } = parsePushPayload(
|
|
||||||
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
|
|
||||||
origin,
|
|
||||||
)
|
|
||||||
const path = clickPath({ url: notice.url }, origin)
|
|
||||||
expect(path).toBe('/')
|
|
||||||
|
|
||||||
await renderHome(path)
|
|
||||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
|
|
||||||
// navigator.serviceWorker is where the worker's postMessage to this
|
|
||||||
// window arrives.
|
|
||||||
const container = new EventTarget()
|
|
||||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
|
||||||
const { fetchMock, server } = stubApi([])
|
|
||||||
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
|
|
||||||
await renderHome('/')
|
|
||||||
const home = await screen.findByRole('heading', { name: 'Pizza night' })
|
|
||||||
await waitFor(() => expect(reads()).toBe(1))
|
|
||||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
// Within the cached list's staleTime the Rotation check comes due and
|
|
||||||
// its notification is tapped while Home is open at /.
|
|
||||||
server.push(SHAKSHUKA)
|
|
||||||
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(win.navigate).not.toHaveBeenCalled()
|
|
||||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
|
||||||
expect(reads()).toBe(2)
|
|
||||||
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Button } from '../ui/Button.tsx'
|
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
|
||||||
import { Photo } from '../ui/Photo.tsx'
|
|
||||||
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
|
|
||||||
import styles from './RotationCheck.module.css'
|
|
||||||
|
|
||||||
// CONFIRMATION is what Home says once the household has answered.
|
|
||||||
const CONFIRMATION: Record<Decision, string> = {
|
|
||||||
keep: 'Added to your Regulars',
|
|
||||||
drop: "Got it — we won't suggest it again",
|
|
||||||
not_made: 'Back in your Quest log',
|
|
||||||
}
|
|
||||||
|
|
||||||
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
|
|
||||||
|
|
||||||
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
|
|
||||||
// photo, "Last night: {dinner}", and the three answers.
|
|
||||||
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
|
||||||
const { dinner } = review
|
|
||||||
return (
|
|
||||||
<section aria-label="Keep it in rotation?" className={styles.card}>
|
|
||||||
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
|
|
||||||
<div className={styles.body}>
|
|
||||||
<p className={styles.kicker}>Last night: {dinner.name}</p>
|
|
||||||
<h2 className={styles.question}>Keep it in rotation?</h2>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<Button disabled={disabled} onClick={() => onDecide('keep')}>
|
|
||||||
Keep it
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
|
|
||||||
Not for us
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
|
|
||||||
We didn't make it
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotationCheck is the Rotation check on Home: the card for the oldest
|
|
||||||
// pending check (GET /api/v1/rotation-reviews), which stays until the
|
|
||||||
// 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. 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()
|
|
||||||
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
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{answered !== undefined && (
|
|
||||||
<p role="status" className={styles.confirmation}>
|
|
||||||
{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
|
|
||||||
key={next.dinner.id}
|
|
||||||
review={next}
|
|
||||||
disabled={decide.isPending}
|
|
||||||
onDecide={(decision) => decide.mutate({ review: next, decision })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
|||||||
import { HOME } from '../test/ApiProviders.tsx'
|
import { HOME } from '../test/ApiProviders.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
||||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
|
||||||
import { HomePage } from './HomePage.tsx'
|
import { HomePage } from './HomePage.tsx'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -28,7 +27,7 @@ const UPDATES = {
|
|||||||
type: 'plan_ready',
|
type: 'plan_ready',
|
||||||
title: 'Your week is drafted',
|
title: 'Your week is drafted',
|
||||||
body: 'Take a look at the Draft board.',
|
body: 'Take a look at the Draft board.',
|
||||||
url: '/plan?week=2026-10-12',
|
url: '/plan',
|
||||||
created_at: minutesAgo(5),
|
created_at: minutesAgo(5),
|
||||||
},
|
},
|
||||||
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||||
@@ -53,7 +52,6 @@ function stubServer(responders: Record<string, Responder> = {}) {
|
|||||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
||||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
||||||
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
|
|
||||||
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
||||||
seen = true
|
seen = true
|
||||||
return new Response(null, { status: 204 })
|
return new Response(null, { status: 204 })
|
||||||
@@ -311,7 +309,7 @@ describe('Recent updates sheet', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, '/plan?week=2026-10-12'],
|
[0, '/plan'],
|
||||||
[1, '/discover'],
|
[1, '/discover'],
|
||||||
[2, '/plan/shopping?week=2026-10-05'],
|
[2, '/plan/shopping?week=2026-10-05'],
|
||||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { HOME } from '../test/ApiProviders.tsx'
|
import { parseUpdates } from './api.ts'
|
||||||
import { parseHome, parseUpdates } from './api.ts'
|
|
||||||
|
|
||||||
const origin = 'https://mealquest.example'
|
const origin = 'https://mealquest.example'
|
||||||
const where = 'GET /api/v1/updates'
|
const where = 'GET /api/v1/updates'
|
||||||
@@ -38,15 +37,3 @@ 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',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
+4
-35
@@ -1,9 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { oneOf, photoId } from '../dinners/api.ts'
|
import { oneOf, photoId } from '../dinners/api.ts'
|
||||||
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
|
import { weekLabel } from '../plan/week.ts'
|
||||||
import { appPath } from '../push/notification.ts'
|
import { appPath } from '../push/notification.ts'
|
||||||
|
|
||||||
export const HOME_PATH = '/api/v1/home'
|
export const HOME_PATH = '/api/v1/home'
|
||||||
@@ -28,13 +27,11 @@ export type Tonight = { id: string; name: string; photo_id: string | undefined;
|
|||||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||||
|
|
||||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||||
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
|
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
||||||
// 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
|
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||||
// something not yet seen.
|
// something not yet seen.
|
||||||
export type Home = {
|
export type Home = {
|
||||||
dateLabel: string
|
dateLabel: string
|
||||||
timezone: string
|
|
||||||
tonight: Tonight | null
|
tonight: Tonight | null
|
||||||
sideQuestCount: number
|
sideQuestCount: number
|
||||||
shoppingList: ShoppingListStatus
|
shoppingList: ShoppingListStatus
|
||||||
@@ -80,9 +77,6 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
|||||||
// parseHome checks a GET /api/v1/home body.
|
// parseHome checks a GET /api/v1/home body.
|
||||||
export function parseHome(where: string, body: unknown): Home {
|
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 (!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) {
|
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||||
}
|
}
|
||||||
@@ -90,7 +84,6 @@ export function parseHome(where: string, body: unknown): Home {
|
|||||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||||
return {
|
return {
|
||||||
dateLabel: body.dateLabel,
|
dateLabel: body.dateLabel,
|
||||||
timezone: body.timezone,
|
|
||||||
tonight: parseTonight(where, body.tonight),
|
tonight: parseTonight(where, body.tonight),
|
||||||
sideQuestCount: body.sideQuestCount as number,
|
sideQuestCount: body.sideQuestCount as number,
|
||||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||||
@@ -98,37 +91,13 @@ export function parseHome(where: string, body: unknown): Home {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isZone says whether zone is an IANA zone this browser knows.
|
// useHome is the Home screen's summary (GET /api/v1/home).
|
||||||
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() {
|
export function useHome() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
return useQuery({
|
||||||
const home = useQuery({
|
|
||||||
queryKey: HOME_KEY,
|
queryKey: HOME_KEY,
|
||||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
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
|
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
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 { photoId } from '../dinners/api.ts'
|
|
||||||
import { isNotificationOpened } from '../push/notification.ts'
|
|
||||||
|
|
||||||
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
|
|
||||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
|
||||||
const DINNERS_KEY = ['dinners'] as const
|
|
||||||
|
|
||||||
// rotationReviewPath is where the household answers "Keep it in rotation?"
|
|
||||||
// for a tried dinner.
|
|
||||||
export function rotationReviewPath(dinnerId: string): string {
|
|
||||||
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
|
|
||||||
// for us") archives it so it is never suggested again, and not_made ("We
|
|
||||||
// didn't make it") returns it to the Quest log. Keep is the only way a tried
|
|
||||||
// dinner becomes a Regular; nothing decides one automatically.
|
|
||||||
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
|
|
||||||
export type Decision = (typeof DECISIONS)[number]
|
|
||||||
|
|
||||||
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
|
|
||||||
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
|
|
||||||
// planned on.
|
|
||||||
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
|
|
||||||
|
|
||||||
function parseReview(where: string, body: unknown): RotationReview {
|
|
||||||
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
|
|
||||||
const dinner = body.dinner
|
|
||||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
|
||||||
throw new Error(`${where}: a Rotation check has no dinner`)
|
|
||||||
}
|
|
||||||
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
|
|
||||||
// pending Rotation checks, oldest plan date first.
|
|
||||||
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
|
|
||||||
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
|
|
||||||
return body.reviews.map((review) => parseReview(where, review))
|
|
||||||
}
|
|
||||||
|
|
||||||
// useRotationReviews is the pending Rotation checks (GET
|
|
||||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
|
||||||
// reads them afresh, and so does a notification tap that focuses Home
|
|
||||||
// already open (the service worker's NotificationOpened), so Home opened
|
|
||||||
// from a rotation_review notification shows the check it is about.
|
|
||||||
export function useRotationReviews() {
|
|
||||||
const { apiFetch } = useApi()
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ROTATION_REVIEWS_KEY,
|
|
||||||
queryFn: async () =>
|
|
||||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
|
||||||
refetchOnMount: 'always',
|
|
||||||
})
|
|
||||||
const { refetch } = query
|
|
||||||
useEffect(() => {
|
|
||||||
// Without a service worker there are no notifications to tap.
|
|
||||||
if (!('serviceWorker' in navigator)) return
|
|
||||||
const container = navigator.serviceWorker
|
|
||||||
const onMessage = (event: MessageEvent<unknown>) => {
|
|
||||||
if (isNotificationOpened(event.data)) void refetch()
|
|
||||||
}
|
|
||||||
container.addEventListener('message', onMessage)
|
|
||||||
return () => container.removeEventListener('message', onMessage)
|
|
||||||
}, [refetch])
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
|
||||||
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
|
|
||||||
// must be that dinner, decided that way.
|
|
||||||
//
|
|
||||||
// Home leaves the check out while its answer is pending (the mutation's
|
|
||||||
// variables say which), so the next one shows at once; a read in flight is
|
|
||||||
// stopped so it cannot bring the check back meanwhile. Once saved, the check
|
|
||||||
// leaves the cached list; once failed, it is shown again as the list has it.
|
|
||||||
// Either way the Rotation checks and the dinners lists (the Regulars and the
|
|
||||||
// Quest log the answer moved it to) are read again.
|
|
||||||
export function useDecideRotationReview() {
|
|
||||||
const { apiFetch } = useApi()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
|
|
||||||
mutationFn: async ({ review, decision }) => {
|
|
||||||
const path = rotationReviewPath(review.dinner.id)
|
|
||||||
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
|
|
||||||
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
|
|
||||||
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
|
||||||
},
|
|
||||||
onSuccess: (_data, { review }) => {
|
|
||||||
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
|
|
||||||
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
|
||||||
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -160,25 +160,16 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||||
// screen.
|
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
|
||||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={[entry]}>
|
<MemoryRouter initialEntries={['/plan']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/plan" element={<PlanPage />} />
|
||||||
path="/plan"
|
|
||||||
element={
|
|
||||||
<>
|
|
||||||
<PlanPage />
|
|
||||||
<Searched />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/plan/shopping" element={shopping} />
|
<Route path="/plan/shopping" element={shopping} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -199,11 +190,6 @@ 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
|
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||||
// queryClient keeps.
|
// queryClient keeps.
|
||||||
function pushesKept(queryClient: QueryClient) {
|
function pushesKept(queryClient: QueryClient) {
|
||||||
@@ -1128,30 +1114,6 @@ describe('PlanPage', () => {
|
|||||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
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 () => {
|
it('moves between weeks with the previous and next week buttons', async () => {
|
||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { ApiError, useJob } from '../api/index.ts'
|
import { ApiError, useJob } from '../api/index.ts'
|
||||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import buttonStyles from '../ui/Button.module.css'
|
import buttonStyles from '../ui/Button.module.css'
|
||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
dayOfMonth,
|
dayOfMonth,
|
||||||
defaultWeek,
|
defaultWeek,
|
||||||
isMonday,
|
|
||||||
localNow,
|
localNow,
|
||||||
weekdayKicker,
|
weekdayKicker,
|
||||||
weekdayName,
|
weekdayName,
|
||||||
@@ -667,28 +666,19 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
|||||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
// 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
|
// 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
|
// 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 the week chosen by
|
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||||
// ?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's plan_draft time has come in the household's zone, otherwise on this
|
||||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
// week. 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
|
// 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.
|
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||||
export function PlanPage() {
|
export function PlanPage() {
|
||||||
const layout = useBreakpoint()
|
const layout = useBreakpoint()
|
||||||
const settings = useHouseholdSettings()
|
const settings = useHouseholdSettings()
|
||||||
const [params, setParams] = useSearchParams()
|
const [week, setWeek] = useState<string | null>(null)
|
||||||
const chosen = params.get(WEEK_PARAM)
|
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||||
// 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
|
let content
|
||||||
if (chosen !== null && !isMonday(chosen)) {
|
if (settings.isError) {
|
||||||
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
|
||||||
} else if (settings.isError) {
|
|
||||||
content = (
|
content = (
|
||||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||||
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.
|
// 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' }
|
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||||
@@ -84,45 +72,4 @@ 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-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
|
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,15 +35,6 @@ export function weekStart(date: string): string {
|
|||||||
return addDays(date, -sinceMonday)
|
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".
|
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||||
export function weekdayKicker(date: string): string {
|
export function weekdayKicker(date: string): string {
|
||||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||||
@@ -124,39 +115,3 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
|||||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
GENERIC_NOTICE,
|
GENERIC_NOTICE,
|
||||||
appPath,
|
appPath,
|
||||||
clickPath,
|
clickPath,
|
||||||
isNotificationOpened,
|
|
||||||
openNotification,
|
openNotification,
|
||||||
parsePushPayload,
|
parsePushPayload,
|
||||||
planClick,
|
planClick,
|
||||||
@@ -179,7 +178,6 @@ describe('openNotification', () => {
|
|||||||
focused,
|
focused,
|
||||||
focus: vi.fn(async () => {}),
|
focus: vi.fn(async () => {}),
|
||||||
navigate: vi.fn(async () => {}),
|
navigate: vi.fn(async () => {}),
|
||||||
postMessage: vi.fn(),
|
|
||||||
} satisfies ClickWindow
|
} satisfies ClickWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,18 +193,6 @@ describe('openNotification', () => {
|
|||||||
expect(win.focus).toHaveBeenCalled()
|
expect(win.focus).toHaveBeenCalled()
|
||||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||||
expect(win.postMessage).not.toHaveBeenCalled()
|
|
||||||
expect(all.openWindow).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
|
|
||||||
const win = window('/', true)
|
|
||||||
const all = clients([win])
|
|
||||||
await openNotification({ url: '/' }, all, origin)
|
|
||||||
expect(win.focus).toHaveBeenCalled()
|
|
||||||
expect(win.navigate).not.toHaveBeenCalled()
|
|
||||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
|
|
||||||
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
|
|
||||||
expect(all.openWindow).not.toHaveBeenCalled()
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -150,18 +150,6 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
|||||||
export type ClickWindow = AppWindow & {
|
export type ClickWindow = AppWindow & {
|
||||||
focus(): Promise<unknown>
|
focus(): Promise<unknown>
|
||||||
navigate(url: string): Promise<unknown>
|
navigate(url: string): Promise<unknown>
|
||||||
postMessage(message: NotificationOpened): void
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationOpened is the message a tap posts to an open window it focuses
|
|
||||||
// without navigating, because the window is already at url: nothing remounts
|
|
||||||
// there, so the screen reads afresh what the notification is about.
|
|
||||||
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
|
|
||||||
|
|
||||||
// isNotificationOpened reports whether a message from the service worker is
|
|
||||||
// NotificationOpened.
|
|
||||||
export function isNotificationOpened(message: unknown): message is NotificationOpened {
|
|
||||||
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClickClients<W extends ClickWindow> = {
|
export type ClickClients<W extends ClickWindow> = {
|
||||||
@@ -170,8 +158,7 @@ export type ClickClients<W extends ClickWindow> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// openNotification carries out a tap on a notification with data: focus
|
// openNotification carries out a tap on a notification with data: focus
|
||||||
// first, while the tap still allows it, then navigate; a window already at
|
// first, while the tap still allows it, then navigate.
|
||||||
// the url is told NotificationOpened instead.
|
|
||||||
export async function openNotification<W extends ClickWindow>(
|
export async function openNotification<W extends ClickWindow>(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
clients: ClickClients<W>,
|
clients: ClickClients<W>,
|
||||||
@@ -184,5 +171,4 @@ export async function openNotification<W extends ClickWindow>(
|
|||||||
}
|
}
|
||||||
await plan.window.focus()
|
await plan.window.focus()
|
||||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||||
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router'
|
import { Link, useSearchParams } from 'react-router'
|
||||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||||
import { ApiError } from '../api/errors.ts'
|
import { ApiError } from '../api/errors.ts'
|
||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||||
@@ -310,3 +310,12 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
|||||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
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,7 +66,6 @@ export const GOOGLE_NOT_CONNECTED = {
|
|||||||
// unread update.
|
// unread update.
|
||||||
export const HOME = {
|
export const HOME = {
|
||||||
dateLabel: 'Friday, Oct 2',
|
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 },
|
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||||
sideQuestCount: 10,
|
sideQuestCount: 10,
|
||||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||||
|
|||||||
Reference in New Issue
Block a user