feat(medications): v1.5 scheduling fields + backfill migration

Adds the columns the v1.5 cadence work needs without breaking any
v1.4-era reader. Pre-existing `windowStart` / `windowEnd` /
`daysOfWeek` stay live; v1.5.x writes both legacy + new; the read
flip lands in v1.5.1 once production traffic has validated the new
fields.

### Medication

- `startsOn DATE?` — course start; anchors RRULE BYDAY / BYMONTHDAY
  patterns and the rolling-interval countdown's first window. NULL
  preserves the legacy implicit "from creation" anchor.
- `endsOn DATE?` — course end; reminder worker stops minting slots
  past this date. NULL = chronic.
- `oneShot BOOLEAN NOT NULL DEFAULT FALSE` — single-administration
  flag (flu shots, vaccines, post-op final doses). Auto-deactivates
  the medication after the single intake is logged.

### MedicationSchedule

- `timesOfDay TEXT[]` — first-class HH:mm entries. Patients think
  in points-in-time, not in windows; the legacy single-window
  primitive misframed the mental model. Migration backfills
  `[windowStart]` for every pre-v1.5 row.
- `reminderGraceMinutes INT?` — replaces the implicit
  `windowEnd - windowStart` span for late-classification. NULL
  falls back to the legacy math.
- `rrule TEXT?` — RFC 5545 RRULE string for calendar-anchored
  cadences (every N weeks, monthly, quarterly, yearly, etc.).
- `rolling_interval_days INT?` — flexible-rolling interval counted
  from the latest intake. Mutually exclusive with `rrule` via the
  `medication_schedules_rrule_xor_rolling` CHECK constraint.

### Backfill

The migration walks every existing `medication_schedules` row and
derives an `rrule` + a single-entry `timesOfDay` from the legacy
encoding. Three branches:

- `days_of_week IS NULL / ""` → `FREQ=DAILY`
- `days_of_week = "1,3,5"` → `FREQ=WEEKLY;BYDAY=MO,WE,FR`
- `days_of_week = "i2;1,3,5"` → `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR`

A `pg_temp` helper handles the HealthLog-weekday → RFC-5545-BYDAY
conversion. Anything unexpected stays NULL so the v1.5.1 reader
falls back to the legacy decoder.

Indexes:

- Partial index `medication_schedules_rolling_idx` on
  `rolling_interval_days IS NOT NULL` so the reminder worker can
  fan in over rolling schedules cheaply on every intake event.

Column comments document the legacy → v1.5 → v1.6 migration plan
so a future reader doesn't accidentally drop the legacy columns
before the read-flip lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 07:52:11 +02:00
1 parent 3480a3d766
commit f80e3375b2
2 files changed
+180 -1

No files matched your search

@@ -0,0 +1,126 @@
-- v1.5 — richer medication-scheduling cadences.
--
-- Adds three medication-level fields (course window + one-shot flag)
-- and four schedule-level fields (times-of-day, reminder grace,
-- RRULE string, rolling-interval-days) alongside the legacy
-- `days_of_week` / `window_start` / `window_end` columns. Existing
-- rows are backfilled in-line so the new readers (v1.5.1 onward)
-- see a populated `rrule` and `times_of_day` for every legacy
-- schedule.
--
-- The migration is additive only; v1.5.x continues to read legacy
-- columns as the source of truth. v1.5.1 flips the readers to the
-- new fields with legacy as fallback; v1.6.0 drops the legacy
-- columns + the `parse/serializeScheduleRecurrence` helpers.
-- ── Medication course window + one-shot flag ───────────────────
ALTER TABLE "medications"
ADD COLUMN "starts_on" DATE,
ADD COLUMN "ends_on" DATE,
ADD COLUMN "one_shot" BOOLEAN NOT NULL DEFAULT FALSE;
-- ── MedicationSchedule v1.5 fields ─────────────────────────────
ALTER TABLE "medication_schedules"
ADD COLUMN "times_of_day" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "reminder_grace_minutes" INT,
ADD COLUMN "rrule" TEXT,
ADD COLUMN "rolling_interval_days" INT;
-- ── Backfill: legacy `days_of_week` → RRULE string + times_of_day ──
-- The encoding rules (per `src/lib/medication-schedule.ts`):
-- NULL / empty → daily
-- "1,3,5" → weekly Mon/Wed/Fri (0=Sun..6=Sat)
-- "i2;1,3,5" → every 2 weeks Mon/Wed/Fri (interval 1..4)
--
-- Weekday mapping HealthLog (Sun=0..Sat=6) → RFC 5545 BYDAY codes
-- (SU, MO, TU, WE, TH, FR, SA). The helper below converts the
-- comma-separated decimal list into the BYDAY substring.
-- Helper: HealthLog weekday number → RFC 5545 code.
CREATE OR REPLACE FUNCTION pg_temp.hl_weekday_to_byday(n int) RETURNS text AS $$
BEGIN
RETURN CASE n
WHEN 0 THEN 'SU'
WHEN 1 THEN 'MO'
WHEN 2 THEN 'TU'
WHEN 3 THEN 'WE'
WHEN 4 THEN 'TH'
WHEN 5 THEN 'FR'
WHEN 6 THEN 'SA'
ELSE NULL
END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Helper: comma-separated decimal list → comma-separated BYDAY codes
-- ("1,3,5" → "MO,WE,FR"). Filters out anything outside the 0..6 range
-- so a legacy row carrying garbage decodes to NULL (the v1.5.1 read-
-- flip then falls back to the legacy decoder, which has its own
-- defensive filter).
CREATE OR REPLACE FUNCTION pg_temp.hl_days_to_byday(days text) RETURNS text AS $$
DECLARE
result text;
BEGIN
IF days IS NULL OR length(trim(days)) = 0 THEN
RETURN NULL;
END IF;
SELECT string_agg(pg_temp.hl_weekday_to_byday(part::int), ',' ORDER BY part::int)
INTO result
FROM unnest(string_to_array(days, ',')) AS part
WHERE part ~ '^[0-6]$';
RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Backfill: each existing schedule row gets a populated `rrule` plus
-- a single-entry `times_of_day` derived from `window_start`. The
-- encoder branches on the three legacy shapes.
UPDATE "medication_schedules" SET
"times_of_day" = ARRAY["window_start"],
"rrule" = CASE
-- Daily (the convention for null / empty / "no valid weekdays")
WHEN "days_of_week" IS NULL
OR length(trim("days_of_week")) = 0
THEN 'FREQ=DAILY'
-- Multi-week with weekday subset ("i2;1,3,5")
WHEN "days_of_week" ~ '^i[1-4];[0-6](,[0-6])*$' THEN
'FREQ=WEEKLY;INTERVAL=' ||
substring("days_of_week" FROM 2 FOR 1) ||
';BYDAY=' ||
pg_temp.hl_days_to_byday(substring("days_of_week" FROM position(';' IN "days_of_week") + 1))
-- Multi-week, interval prefix only ("i2;")
WHEN "days_of_week" ~ '^i[1-4];$' THEN
'FREQ=WEEKLY;INTERVAL=' ||
substring("days_of_week" FROM 2 FOR 1)
-- Legacy weekday-list ("1,3,5")
WHEN "days_of_week" ~ '^[0-6](,[0-6])*$' THEN
'FREQ=WEEKLY;BYDAY=' || pg_temp.hl_days_to_byday("days_of_week")
-- Anything unexpected stays NULL so the v1.5.1 read-flip falls
-- back to the legacy decoder.
ELSE NULL
END
WHERE TRUE;
-- ── XOR constraint: rrule and rolling_interval_days are mutually
-- exclusive. Both NULL is allowed (legacy rows + future migrations).
ALTER TABLE "medication_schedules"
ADD CONSTRAINT "medication_schedules_rrule_xor_rolling"
CHECK ("rrule" IS NULL OR "rolling_interval_days" IS NULL);
-- ── Indexes ────────────────────────────────────────────────────
-- A targeted index on `rolling_interval_days IS NOT NULL` lets the
-- reminder worker quickly find every rolling-cadence schedule in
-- the system on the cron tick (rolling cadences are event-driven so
-- the worker needs to fan in over them on every intake).
CREATE INDEX "medication_schedules_rolling_idx"
ON "medication_schedules" ("rolling_interval_days")
WHERE "rolling_interval_days" IS NOT NULL;
-- Documentation comment on the legacy column so a future reader
-- understands the v1.5 → v1.6 migration plan.
COMMENT ON COLUMN "medication_schedules"."days_of_week" IS
'Legacy v1.4 cadence string encoding. Backfilled into `rrule` by the v1.5 migration; readers consult `rrule` first as of v1.5.1; column drops in v1.6.0.';
COMMENT ON COLUMN "medication_schedules"."window_start" IS
'Legacy v1.4 reminder-window start. `times_of_day[0]` is the v1.5 first-class equivalent; column kept through v1.5.x for backwards-compat.';
COMMENT ON COLUMN "medication_schedules"."window_end" IS
'Legacy v1.4 reminder-window end. `reminder_grace_minutes` is the v1.5 first-class equivalent; column kept through v1.5.x for backwards-compat.';
+54 -1
View File
@@ -784,6 +784,24 @@ model Medication {
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
snoozedUntil DateTime? @map("snoozed_until")
/// v1.5 — date the medication course begins. Anchors RRULE BYDAY /
/// BYMONTHDAY patterns and the rolling-interval countdown's first
/// window. NULL = "active from creation" (the legacy implicit
/// behaviour preserved for backwards compatibility). Required when
/// `oneShot` is true.
startsOn DateTime? @map("starts_on") @db.Date
/// v1.5 — date the medication course ends. Reminder worker stops
/// minting slots past this date. NULL = no end date (chronic).
/// When `oneShot` is true, `endsOn` always equals `startsOn`.
endsOn DateTime? @map("ends_on") @db.Date
/// v1.5 — single-administration medication (flu shot, vaccine,
/// post-op single dose, etc.). When true the medication has at
/// most one MedicationIntakeEvent and `active` flips to false once
/// that intake is logged. Mutually exclusive with rolling or
/// recurring schedules; the form layer enforces the constraint and
/// the migration backfills `oneShot = false` for every existing
/// row.
oneShot Boolean @default(false) @map("one_shot")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -829,7 +847,42 @@ model MedicationSchedule {
windowEnd String @map("window_end") // HH:mm format, e.g. "10:00"
label String? // e.g. "Morning", "Evening"
dose String? // Per-schedule dose override; null = use Medication.dose
daysOfWeek String? @map("days_of_week") // Comma-separated day numbers (0=Sun..6=Sat), null = daily
/// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5").
/// Kept for reads of pre-v1.5 rows. v1.5 writes go to `rrule` or
/// `rollingIntervalDays`; the migration backfills `rrule` for every
/// existing row and v1.5.1 flips the readers to consult the new
/// fields first. v1.6.0 drops this column.
daysOfWeek String? @map("days_of_week")
/// v1.5 — one or more `HH:mm` strings naming the times the dose is
/// taken (in the user's timezone). Replaces the legacy single
/// `windowStart` mental model — patients think in points-in-time,
/// not in windows. Migration backfills `[windowStart]` for every
/// pre-v1.5 row.
timesOfDay String[] @default([]) @map("times_of_day")
/// v1.5 — reminder grace window in minutes. Replaces the implicit
/// `windowEnd - windowStart` computation for late-dose
/// classification. NULL falls back to the legacy math (the
/// reminder worker honours `windowEnd` when this is null).
reminderGraceMinutes Int? @map("reminder_grace_minutes")
/// v1.5 — RFC 5545 RRULE string for calendar-anchored cadences
/// (every N weeks, monthly, quarterly, yearly). Examples:
/// FREQ=DAILY
/// FREQ=WEEKLY;BYDAY=MO,WE,FR
/// FREQ=WEEKLY;INTERVAL=2;BYDAY=WE
/// FREQ=MONTHLY;BYMONTHDAY=1
/// FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10
/// FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1
/// Mutually exclusive with `rollingIntervalDays` — enforced by the
/// `medication_schedules_rrule_xor_rolling` CHECK constraint added
/// in the v1.5 migration.
rrule String? @map("rrule")
/// v1.5 — flexible-rolling interval in days, counted forward from
/// the latest `MedicationIntakeEvent.takenAt` for the parent
/// medication. NULL when the schedule is calendar-anchored.
/// Mutually exclusive with `rrule`. Range 1..365 enforced at the
/// Zod layer; the column itself is unconstrained at the DB so
/// future extensions don't need a new migration.
rollingIntervalDays Int? @map("rolling_interval_days")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]