github MBombeck/HealthLog v1.5.3
v1.5.3 — Medication scheduling: RRULE cadences, rolling intervals, one-shot lifecycle, creation wizard

4 hours ago

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=1 all expand correctly. Powered by the rrule npm package; the engine adds an UNTIL suffix derived from the medication's endsOn automatically (and skips that suffix when the user's RRULE already carries COUNT or UNTIL, 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 past endsOn.
  • 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 via allowedKinds), TimesOfDayChips (one or more HH:mm entries with morning / noon / evening / night presets), CourseWindowRow (start + optional end with lockEndsToStart for 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.ts exposing occurrencesBetween, nextOccurrenceAfter, and matchesInstant. The reminder worker routes through the engine via a narrow worker-helpers.ts adapter; 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, backfills rrule and timesOfDay for every existing schedule (closed-enum regex on the legacy daysOfWeek shapes, ELSE NULL fallback), and stages a CHECK constraint forbidding both rrule and rolling_interval_days populated 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 new POST /api/medications/extract — registered in src/lib/openapi/routes.ts and regenerated into docs/api/openapi.yaml. The rrule XOR rollingIntervalDays invariant is documented at both the schema description and the per-field descriptions so iOS code-gen surfaces the mutual exclusion.

Fixed

  • Bi-weekly worker regressionsrc/lib/jobs/reminder-worker.ts now consumes the canonical engine, which honours intervalWeeks > 1 via 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.tsdaysOfWeek = "i2;3" emits 2 of 4 candidate Wednesdays in a 4-week window).
  • One-shot lifecycle reconciliationsrc/lib/medications/lifecycle.ts defines reconcileOneShotState(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 as active: 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 startsOn floorexpandLegacy now respects medication.startsOn the way every other dispatch tier does. A legacy-shape schedule with a future startsOn no longer emits historical slots between today and the start date.
  • Course-window invariants — the create and update schemas refuse oneShot: true without startsOn (the design contract requires the anchor date) and refuse any course where endsOn < startsOn (which previously produced a silently dead medication). Rolling-cadence schedules cap timesOfDay at one entry to match the engine's single-time emission.
  • Wizard notificationsEnabled toggle — 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 default notificationsEnabled = true.
  • Edit form: one-shot single source of truth — the medication-level oneShot switch now drives the picker's allowedKinds, so the per-schedule picker can no longer encode kind: "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-busy while the submit is in flight.

Changed

  • Recurrence-engine defence-in-depthnextOccurrenceAfter carries a MAX_CHUNKS = 80 cap alongside the pre-existing 10-year hardCap, 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 via annotate({ action: "medications.rrule.parse_error" }) instead of silently returning [].
  • Edit form refactorsrc/components/medications/medication-form.tsx now composes the picker primitives for the edit path. Pre-v1.5 medications round-trip through inferCadenceFromLegacy on 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, endsOn cap, missing startsOn, multi-schedule fan-out).
  • Eight tests/integration/v15-cadence-shapes.integration.test.ts cases 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 / intervalWeeks columns directly. A wizard-minted medication with rollingIntervalDays = 7 or 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 rollingIntervalDays invariant is enforced at four runtime layers (Zod refine, route invariant, engine dispatch, DB CHECK). The OpenAPI surface documents the constraint in prose; a structural oneOf discriminator lands before the iOS v0.7.x cut.
  • Worker lastIntakeAt aggregation. The reminder worker currently fires one findFirst per rolling-medication per 15-minute tick. Functionally correct; one groupBy collapses 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:check all green at HEAD.

Don't miss a new HealthLog release

NewReleases is sending notifications on new releases.