mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user