The medication surface in v1.5.2 covered daily and weekday-subset schedules cleanly, but everything else — bi-weekly, monthly, quarterly, yearly, "every N days from my last injection", single-dose appointments — either drifted or was unreachable from the UI. The reminder worker also carried a quiet pre-existing bug where intervalWeeks was ignored: a schedule meant to fire every other Wednesday fired every Wednesday. This release lands the full cadence surface, closes the worker bug behind a regression test, and adds a step-driven creation flow patients can walk without consulting a manual.
Added
- Four new cadence shapes, modelled at the schema level with explicit columns instead of an overloaded legacy string:
- Calendar-anchored RRULE patterns (
rrule TEXT) —FREQ=WEEKLY;INTERVAL=2;BYDAY=WE,FREQ=MONTHLY;BYMONTHDAY=1,FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10,FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1all expand correctly. Powered by therrulenpm package; the engine adds anUNTILsuffix derived from the medication'sendsOnautomatically (and skips that suffix when the user's RRULE already carriesCOUNTorUNTIL, so the two never collide). - Flexible-rolling cadence (
rollingIntervalDays INT) — "every N days from the last logged intake". The next-due date re-anchors when an intake is logged; skipped doses pause the schedule until the next real intake. The driving use case is the GLP-1 weekly injection where the calendar Wednesday does not match the user's actual cycle. - One-shot single-administration (
oneShot BOOLEAN) — vaccines, post-op final doses, anything with one scheduled occurrence and an auto-deactivate after the dose is logged. - Course window (
startsOn DATE,endsOn DATE) at the medication level. Required for one-shot; optional everywhere else. The reminder worker stops minting slots pastendsOn.
- Calendar-anchored RRULE patterns (
POST /api/medications/extract— natural-language extraction for the wizard's first step. A user types "Mounjaro 5mg weekly Wednesday morning starting next Monday" and the route returns a structured payload that pre-fills the wizard. Rate-limited per user, budget-gated, and carries a citation-coverage guard so the extraction cannot return a name or dose the user did not write./medications/new— the seven-step creation wizard (compressed to five on the one-shot path) replacing cold-start entry into the legacy flat form. The summary step interpolates the actual picked weekdays / day-of-month / yearly date so the patient confirms the specifics rather than a category label.- Reusable picker primitives under
src/components/medications/scheduling/:CadencePicker(eight cadence kinds, mode-aware viaallowedKinds),TimesOfDayChips(one or moreHH:mmentries with morning / noon / evening / night presets),CourseWindowRow(start + optional end withlockEndsToStartfor one-shot). Composed by both the wizard and the refactored edit form so any future cadence tweak reaches both surfaces at once. - Canonical recurrence engine at
src/lib/medications/scheduling/recurrence.tsexposingoccurrencesBetween,nextOccurrenceAfter, andmatchesInstant. The reminder worker routes through the engine via a narrowworker-helpers.tsadapter; the today-projector, the cadence chart, and the medication card continue to read the legacy fields through v1.5.x and switch over in the v1.5.4 read-flip. - Migration
0081_v15_medication_scheduling— adds the new columns, backfillsrruleandtimesOfDayfor every existing schedule (closed-enum regex on the legacydaysOfWeekshapes, ELSE NULL fallback), and stages a CHECK constraint forbidding bothrruleandrolling_interval_dayspopulated on the same row. - OpenAPI coverage for every medication route —
GET / POST / PUT / DELETE /api/medications,GET /api/medications/{id},POST /api/medications/{id}/intake,GET /api/medications/{id}/cadence, and the newPOST /api/medications/extract— registered insrc/lib/openapi/routes.tsand regenerated intodocs/api/openapi.yaml. Therrule XOR rollingIntervalDaysinvariant is documented at both the schema description and the per-field descriptions so iOS code-gen surfaces the mutual exclusion.
Fixed
- Bi-weekly worker regression —
src/lib/jobs/reminder-worker.tsnow consumes the canonical engine, which honoursintervalWeeks > 1via the legacy fallback's week-phase math. A bi-weekly schedule that previously fired every Wednesday now correctly fires every other Wednesday. Pinned by an explicit regression test (recurrence.test.ts—daysOfWeek = "i2;3"emits 2 of 4 candidate Wednesdays in a 4-week window). - One-shot lifecycle reconciliation —
src/lib/medications/lifecycle.tsdefinesreconcileOneShotState(prisma, medicationId, userId)and the helper runs after every intake mutation (POST + PUT + DELETE). A user who logs the single dose then immediately undoes the log gets the medication back asactive: true; a user who flips an existing intake from real to skipped also gets the medication reactivated. The helper is a no-op on non-one-shot medications. - Legacy fallback
startsOnfloor —expandLegacynow respectsmedication.startsOnthe way every other dispatch tier does. A legacy-shape schedule with a futurestartsOnno longer emits historical slots between today and the start date. - Course-window invariants — the create and update schemas refuse
oneShot: truewithoutstartsOn(the design contract requires the anchor date) and refuse any course whereendsOn < startsOn(which previously produced a silently dead medication). Rolling-cadence schedules captimesOfDayat one entry to match the engine's single-time emission. - Wizard
notificationsEnabledtoggle — the Step 7 reminders switch now actually reaches the POST body. Earlier the toggle was visually live but its value was discarded by the body builder and every wizard-created medication ended up with the defaultnotificationsEnabled = true. - Edit form: one-shot single source of truth — the medication-level
oneShotswitch now drives the picker'sallowedKinds, so the per-schedule picker can no longer encodekind: "oneShot"while the medication-level switch is off (or vice versa). Toggling the switch on with multiple schedules surfaces a confirmation toast before the collapse-to-single-schedule, instead of silently dropping the extras. - Wizard accessibility and tap-target hygiene — focus advances into the new step's first input on Next; the cadence-picker rows are now full-row click targets (44 px); the nav buttons (Back / Next / Create) and the natural-language trigger rise to 44 px; the wizard card carries
aria-busywhile the submit is in flight.
Changed
- Recurrence-engine defence-in-depth —
nextOccurrenceAftercarries aMAX_CHUNKS = 80cap alongside the pre-existing 10-yearhardCap, so a pathological RRULE that walks zero forward (e.g. leap-day-only) can no longer compound through many 90-day chunks. RRULE parse failures now surface viaannotate({ action: "medications.rrule.parse_error" })instead of silently returning[]. - Edit form refactor —
src/components/medications/medication-form.tsxnow composes the picker primitives for the edit path. Pre-v1.5 medications round-trip throughinferCadenceFromLegacyon load and dual-write both shapes on save, so the existing legacy schedules keep working unchanged. - i18n — 111 new keys × six locales populating the wizard, the picker primitives, the natural-language overlay, the edit-form sections, and the plain-language cadence summary. German and English carry native copy; Spanish, French, Italian, Polish ship the English string verbatim as a machine fallback for this cut. Native polish for the four fallback locales follows.
Tests
src/lib/medications/scheduling/__tests__/recurrence.test.ts— 28 unit cases covering every cadence kind plus the edge-case matrix (DST spring-forward, timezone shift mid-course, skipped + late + retroactive doses, paused medication,endsOncap, missingstartsOn, multi-schedule fan-out).- Eight
tests/integration/v15-cadence-shapes.integration.test.tscases exercising every cadence shape against a testcontainer Postgres, plus the one-shot lifecycle (take → reconcile-deactivate → delete-intake → reconcile-reactivate → put-skip → reconcile-reactivate). src/components/medications/scheduling/__tests__/{CadencePicker,TimesOfDayChips,CourseWindowRow,CreationWizard}.test.tsx— 90 component tests against the picker primitives and the wizard helpers (validateStep,buildCreateBody,summariseCadence,progressIndices,allowedKinds).src/lib/ai/coach/__tests__/medication-extract-prompt.test.ts— five snapshot cases pinning the extraction prompt across the cadence shapes plus a citation-coverage guard test.src/app/api/medications/extract/__tests__/route.test.ts— six route cases covering auth, rate limit, budget, missing provider, parse failure, and the happy path.e2e/medications-wizard-{daily,weekdays,biweekly,monthly,rolling,oneshot}.spec.ts— six Playwright specs walking the wizard end-to-end via the German label surface, one per cadence shape. CI runs the full suite; locally the specs typecheck without executing the Next.js prod build.
Notes
- Dashboard read-flip caveat (v1.5.x window). Until the v1.5.4 read-flip lands, the dashboard card, the cadence chart, and the medication-card "next dose" line read the legacy
daysOfWeek/intervalWeekscolumns directly. A wizard-minted medication withrollingIntervalDays = 7or an RRULE encoding renders a daily-looking next-due chip on the dashboard. The reminder worker, the integration tests, and the canonical engine all consume the new fields correctly — the schedule fires on the right date, only the visual chip on the card lags. The read-flip with legacy fallback is the next release. - OpenAPI structural enforcement. The
rrule XOR rollingIntervalDaysinvariant is enforced at four runtime layers (Zod refine, route invariant, engine dispatch, DB CHECK). The OpenAPI surface documents the constraint in prose; a structuraloneOfdiscriminator lands before the iOS v0.7.x cut. - Worker
lastIntakeAtaggregation. The reminder worker currently fires onefindFirstper rolling-medication per 15-minute tick. Functionally correct; onegroupBycollapses it to a single round-trip per tick. Deferred to v1.5.4 with the read-flip. - Test totals. 5494 unit + 1 skipped, 261 integration + 3 skipped, 12 Playwright wizard instances. Typecheck, lint, and
openapi:checkall green at HEAD.