mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #309 from MBombeck/release/v1.16.3
Release v1.16.3 — schedules remember their history
This commit is contained in:
45 files changed
+2347
-268
No files matched your search
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.3] — 2026-06-10 — schedules remember their history
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Changing a schedule no longer rewrites the past.** Every material cadence change now archives the outgoing schedule as a dated revision, and history, compliance and write attribution evaluate each period against the schedule that was valid at the time — a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistently dated old anchors behind an explicit flag.
|
||||
- **The welcome tour can no longer strand its buttons below the fold.** The target scrolls into view first, the popover flips above it when space runs out, and a centred sheet takes over on any viewport when neither fits.
|
||||
- The avatar menu drops its about entry; the section lives in settings.
|
||||
|
||||
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
|
||||
|
||||
### Added
|
||||
|
||||
+34
-10
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.16.1
|
||||
version: 1.16.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -8027,10 +8027,17 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
`nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s
|
||||
staleness is accepted.
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's
|
||||
anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era),
|
||||
this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future
|
||||
slot. The list GET is cached 60 s, so a 60 s staleness is accepted."
|
||||
nextDueOverdue:
|
||||
type: boolean
|
||||
description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the
|
||||
slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future
|
||||
next-due (and when `nextDueAt` is null). Read-only — computed, not stored."
|
||||
startsOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -8092,6 +8099,7 @@ components:
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
- nextDueOverdue
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
@@ -8202,10 +8210,17 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
`nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s
|
||||
staleness is accepted.
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's
|
||||
anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era),
|
||||
this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future
|
||||
slot. The list GET is cached 60 s, so a 60 s staleness is accepted."
|
||||
nextDueOverdue:
|
||||
type: boolean
|
||||
description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the
|
||||
slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future
|
||||
next-due (and when `nextDueAt` is null). Read-only — computed, not stored."
|
||||
startsOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -8254,6 +8269,7 @@ components:
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
- nextDueOverdue
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
@@ -12364,10 +12380,17 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest
|
||||
`nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s
|
||||
staleness is accepted.
|
||||
(paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's
|
||||
anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era),
|
||||
this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future
|
||||
slot. The list GET is cached 60 s, so a 60 s staleness is accepted."
|
||||
nextDueOverdue:
|
||||
type: boolean
|
||||
description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the
|
||||
slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future
|
||||
next-due (and when `nextDueAt` is null). Read-only — computed, not stored."
|
||||
startsOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -12414,6 +12437,7 @@ components:
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
- nextDueOverdue
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
|
||||
@@ -115,3 +115,32 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
The script is idempotent: a second `--fix` run finds zero duplicate groups
|
||||
and changes nothing. Exit code is 0 on success (including a clean
|
||||
zero-findings run), non-zero on bad arguments or a fatal error.
|
||||
|
||||
## Era backfill (`--backfill-eras`, v1.16.3)
|
||||
|
||||
Schedule edits made before v1.16.3 replaced the schedule rows without
|
||||
archiving the old state, so history reads past days against the current
|
||||
times. Section 5 of the script infers the lost era from the recorded
|
||||
slot anchors:
|
||||
|
||||
- A medication qualifies when its anchor-shaped rows (user-tz `HH:mm` on
|
||||
a 5-minute grid) deviate from the **current** `times_of_day` for at
|
||||
least 7 consecutive recorded days before the current times first
|
||||
appear, and it has **no** existing `medication_schedule_revisions`
|
||||
row (re-runs are idempotent).
|
||||
- The proposal is one revision row: `valid_from` = the first deviating
|
||||
row's instant, `valid_until` = local midnight of the first day on the
|
||||
current times, payload = one `FREQ=DAILY` schedule carrying the
|
||||
observed old times.
|
||||
- A `starts_on` that postdates the first recorded row is flagged and
|
||||
pulled back to that row's instant.
|
||||
|
||||
The default run only **reports** the proposals. Apply them explicitly:
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
```
|
||||
|
||||
Review the dry-run table first — the inference is a heuristic. A wrongly
|
||||
created revision can be deleted from `medication_schedule_revisions`
|
||||
without side effects (the read paths fall back to live-only minting).
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} Dosen",
|
||||
"lastIntake": "Letzte Einnahme:",
|
||||
"nextIntake": "Nächste Einnahme:",
|
||||
"nextIntakeOverdue": "Überfällig · {time} — noch nachholbar",
|
||||
"dayStreak": "Tage Serie",
|
||||
"cycleNextDoseToday": "Nächste Dosis heute",
|
||||
"cycleNextDoseTomorrow": "Nächste Dosis morgen",
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} doses",
|
||||
"lastIntake": "Last intake:",
|
||||
"nextIntake": "Next intake:",
|
||||
"nextIntakeOverdue": "Overdue · {time} — can still be taken",
|
||||
"dayStreak": "day streak",
|
||||
"cycleNextDoseToday": "Next dose today",
|
||||
"cycleNextDoseTomorrow": "Next dose tomorrow",
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} dosis",
|
||||
"lastIntake": "Última toma:",
|
||||
"nextIntake": "Próxima toma:",
|
||||
"nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar",
|
||||
"dayStreak": "días de racha",
|
||||
"cycleNextDoseToday": "Próxima dosis hoy",
|
||||
"cycleNextDoseTomorrow": "Próxima dosis mañana",
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} doses",
|
||||
"lastIntake": "Dernière prise :",
|
||||
"nextIntake": "Prochaine prise :",
|
||||
"nextIntakeOverdue": "En retard · {time} — encore rattrapable",
|
||||
"dayStreak": "jours de série",
|
||||
"cycleNextDoseToday": "Prochaine dose aujourd'hui",
|
||||
"cycleNextDoseTomorrow": "Prochaine dose demain",
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} dosi",
|
||||
"lastIntake": "Ultima assunzione:",
|
||||
"nextIntake": "Prossima assunzione:",
|
||||
"nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile",
|
||||
"dayStreak": "giorni di serie",
|
||||
"cycleNextDoseToday": "Prossima dose oggi",
|
||||
"cycleNextDoseTomorrow": "Prossima dose domani",
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
"complianceDoses": "{count} dawek",
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć",
|
||||
"dayStreak": "dni serii",
|
||||
"cycleNextDoseToday": "Następna dawka dzisiaj",
|
||||
"cycleNextDoseTomorrow": "Następna dawka jutro",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.16.1",
|
||||
"version": "1.16.3",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.16.3 — schedule-revision archive (effective dating).
|
||||
--
|
||||
-- A medication whose schedule times are edited used to lose its history: the
|
||||
-- wholesale replace (deleteMany + create) left only the CURRENT rows, so
|
||||
-- bands for PAST days were minted from the new times — the entire old era
|
||||
-- read "unscheduled" and the new times' past slots read "missed".
|
||||
--
|
||||
-- This table archives each SUPERSEDED schedule state as one revision row
|
||||
-- covering `[valid_from, valid_until)`. The LIVE rows stay in
|
||||
-- `medication_schedules`; a revision never describes the current state, so
|
||||
-- `valid_until` is NOT NULL by construction. `payload` holds the full
|
||||
-- snapshot of the replaced schedule rows (timesOfDay, windows, cadence
|
||||
-- fields, doseWindows, label, dose, reminderGraceMinutes) — enough to
|
||||
-- rebuild the canonical schedule list for era minting.
|
||||
--
|
||||
-- `starts_on` stays the global floor: the first era begins at
|
||||
-- max(medication.created_at, starts_on).
|
||||
--
|
||||
-- Additive + non-destructive: a new empty table; no existing row changes.
|
||||
CREATE TABLE "medication_schedule_revisions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"valid_from" TIMESTAMPTZ(6) NOT NULL,
|
||||
"valid_until" TIMESTAMPTZ(6) NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_schedule_revisions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "medication_schedule_revisions_medication_id_valid_from_idx"
|
||||
ON "medication_schedule_revisions"("medication_id", "valid_from");
|
||||
|
||||
ALTER TABLE "medication_schedule_revisions"
|
||||
ADD CONSTRAINT "medication_schedule_revisions_medication_id_fkey"
|
||||
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1335,6 +1335,12 @@ model Medication {
|
||||
/// on the linked MedicationIntakeEvent rows refresh the row inline;
|
||||
/// the read paths consume this instead of the per-event findMany.
|
||||
complianceRollups MedicationComplianceRollup[]
|
||||
/// v1.16.3 — archived schedule eras (effective dating). Superseded
|
||||
/// schedule states land here when a wholesale schedule replace changes
|
||||
/// cadence-relevant fields; the band minter segments historical minting
|
||||
/// ranges into eras so past days read against the schedule that was
|
||||
/// live THEN, not the current one.
|
||||
scheduleRevisions MedicationScheduleRevision[]
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1431,6 +1437,47 @@ model MedicationSchedule {
|
||||
@@map("medication_schedules")
|
||||
}
|
||||
|
||||
/// v1.16.3 — schedule-revision archive (effective dating). One row per
|
||||
/// SUPERSEDED schedule state of a medication: when the schedule set is
|
||||
/// wholesale-replaced with materially different cadence fields, the old
|
||||
/// rows are archived here as a single revision covering
|
||||
/// `[validFrom, validUntil)`. The LIVE rows stay in
|
||||
/// `medication_schedules` — a revision never describes the current
|
||||
/// state, so `validUntil` is always set (no open-ended rows).
|
||||
///
|
||||
/// The band minter segments any historical minting range into eras:
|
||||
/// each revision interval mints with ITS archived schedules, the
|
||||
/// remainder (from the newest `validUntil` onward) mints with the live
|
||||
/// rows. Era boundary rule: a slot belongs to the era its anchor lies
|
||||
/// in; an on-time/late tail may reach past the boundary, but no new
|
||||
/// anchor is minted beyond it.
|
||||
///
|
||||
/// `startsOn` stays the global floor for ALL eras: the first era begins
|
||||
/// at `max(medication.createdAt, startsOn)`; a times-only edit must
|
||||
/// never move `startsOn`.
|
||||
model MedicationScheduleRevision {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
/// Inclusive start of the era this archived state was live for.
|
||||
/// Chains from the previous revision's `validUntil`, or from
|
||||
/// `medication.createdAt` for the first revision.
|
||||
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
|
||||
/// Exclusive end of the era — the instant the replace happened.
|
||||
validUntil DateTime @map("valid_until") @db.Timestamptz(6)
|
||||
/// Full snapshot of the superseded schedule rows: an array of
|
||||
/// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule,
|
||||
/// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks,
|
||||
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
|
||||
/// to rebuild a `CanonicalSchedule` list for era minting.
|
||||
payload Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([medicationId, validFrom])
|
||||
@@map("medication_schedule_revisions")
|
||||
}
|
||||
|
||||
enum IntakeSource {
|
||||
WEB
|
||||
API
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
* anchors — they linger as phantom open doses. Repair: tombstone
|
||||
* with `--fix` (same soft-delete shape as defect 1).
|
||||
*
|
||||
* 5. ERA INFERENCE (v1.16.3) — medications edited BEFORE schedule
|
||||
* versioning existed lost their old era: history reads against the
|
||||
* current times and the old takes look off-schedule. When the
|
||||
* recorded slot anchors (user-tz HH:mm on a 5-minute grid) deviate
|
||||
* from the CURRENT `times_of_day` for >= 7 consecutive recorded days
|
||||
* before the current times first appear, the script proposes one
|
||||
* `medication_schedule_revisions` row (validFrom = first deviating
|
||||
* row, validUntil = first day on the current times). It also flags a
|
||||
* `starts_on` that postdates the first recorded row. Report always;
|
||||
* CREATE/UPDATE only with `--backfill-eras`. Medications that
|
||||
* already have a revision are skipped (idempotent).
|
||||
*
|
||||
* After any mutation the affected `(user, medication, day)` compliance
|
||||
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
|
||||
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
|
||||
@@ -75,6 +87,9 @@
|
||||
* # apply
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
*
|
||||
* # create the proposed schedule revisions + startsOn fixes (defect 5)
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
*/
|
||||
import { Client, types } from "pg";
|
||||
|
||||
@@ -101,6 +116,8 @@ function utcParam(date: Date): string {
|
||||
interface CliOptions {
|
||||
fix: boolean;
|
||||
tombstoneImplausible: boolean;
|
||||
/** v1.16.3 — create the proposed schedule revisions / startsOn fixes. */
|
||||
backfillEras: boolean;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
@@ -108,6 +125,7 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
fix: false,
|
||||
tombstoneImplausible: false,
|
||||
backfillEras: false,
|
||||
userId: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -116,6 +134,8 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
opts.fix = true;
|
||||
} else if (arg === "--tombstone-implausible") {
|
||||
opts.tombstoneImplausible = true;
|
||||
} else if (arg === "--backfill-eras") {
|
||||
opts.backfillEras = true;
|
||||
} else if (arg === "--user" && argv[i + 1]) {
|
||||
opts.userId = argv[i + 1];
|
||||
i += 1;
|
||||
@@ -374,6 +394,10 @@ interface Summary {
|
||||
windowsReconciled: number;
|
||||
staleAnchorRows: number;
|
||||
staleAnchorTombstoned: number;
|
||||
eraCandidates: number;
|
||||
eraRevisionsCreated: number;
|
||||
startsOnCandidates: number;
|
||||
startsOnAdjusted: number;
|
||||
rollupsRecomputed: number;
|
||||
}
|
||||
|
||||
@@ -429,6 +453,10 @@ async function main(): Promise<void> {
|
||||
windowsReconciled: 0,
|
||||
staleAnchorRows: 0,
|
||||
staleAnchorTombstoned: 0,
|
||||
eraCandidates: 0,
|
||||
eraRevisionsCreated: 0,
|
||||
startsOnCandidates: 0,
|
||||
startsOnAdjusted: 0,
|
||||
rollupsRecomputed: 0,
|
||||
};
|
||||
|
||||
@@ -807,13 +835,245 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 5. Recompute the affected compliance rollups so scheduled/taken
|
||||
// 5. Era inference (v1.16.3) — medications whose historical slot
|
||||
// anchors deviate from the CURRENT times_of_day for >= 7
|
||||
// consecutive recorded days before the current times appear: the
|
||||
// schedule was edited pre-versioning and the old era was lost.
|
||||
// Propose one MedicationScheduleRevision per medication
|
||||
// (validFrom = first deviating row, validUntil = first day on the
|
||||
// current times) plus a startsOn correction when startsOn
|
||||
// postdates the first recorded row. Report always; CREATE only
|
||||
// with --backfill-eras. Only medications with ZERO existing
|
||||
// revisions are considered, so the section is idempotent.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const eraRowsRes = await client.query<{
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
created_at: Date;
|
||||
starts_on: Date | null;
|
||||
scheduled_for: Date;
|
||||
}>(
|
||||
`
|
||||
SELECT e."user_id", e."medication_id", m."name" AS medication_name,
|
||||
m."created_at", m."starts_on", e."scheduled_for"
|
||||
FROM "medication_intake_events" e
|
||||
JOIN "medications" m ON m."id" = e."medication_id"
|
||||
WHERE e."deleted_at" IS NULL
|
||||
AND (e."taken_at" IS NOT NULL OR e."skipped" OR e."auto_missed")
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "medication_schedule_revisions" r
|
||||
WHERE r."medication_id" = e."medication_id"
|
||||
)
|
||||
${userFilter}
|
||||
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
/** Per-medication actioned rows, chronological. */
|
||||
const byMedication = new Map<string, typeof eraRowsRes.rows>();
|
||||
for (const row of eraRowsRes.rows) {
|
||||
const list = byMedication.get(row.medication_id) ?? [];
|
||||
list.push(row);
|
||||
byMedication.set(row.medication_id, list);
|
||||
}
|
||||
|
||||
interface EraProposal {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
timesOfDay: string[];
|
||||
validFrom: Date;
|
||||
validUntil: Date;
|
||||
dayCount: number;
|
||||
}
|
||||
interface StartsOnProposal {
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
startsOn: Date;
|
||||
firstRow: Date;
|
||||
}
|
||||
const eraProposals: EraProposal[] = [];
|
||||
const startsOnProposals: StartsOnProposal[] = [];
|
||||
|
||||
for (const [medicationId, rows] of byMedication) {
|
||||
const tz = await userTimezone(rows[0].user_id);
|
||||
const anchors = await medicationAnchors(medicationId);
|
||||
if (anchors.size === 0) continue; // PRN / unscheduled — nothing to infer.
|
||||
|
||||
// startsOn AFTER the first recorded row — the course began earlier
|
||||
// than the configured start; propose pulling startsOn back.
|
||||
const startsOn = rows[0].starts_on;
|
||||
if (startsOn && startsOn.getTime() > rows[0].scheduled_for.getTime()) {
|
||||
startsOnProposals.push({
|
||||
medicationId,
|
||||
medicationName: rows[0].medication_name,
|
||||
startsOn,
|
||||
firstRow: rows[0].scheduled_for,
|
||||
});
|
||||
}
|
||||
|
||||
// Walk the recorded days chronologically. Only ANCHOR-shaped rows
|
||||
// count: a scheduled_for whose user-tz HH:mm sits on a 5-minute
|
||||
// grid (reminder-/projector-minted slot instants land on the
|
||||
// configured times; free-hand ad-hoc takes rarely do).
|
||||
const dayTimes = new Map<string, Set<string>>();
|
||||
const dayOrder: string[] = [];
|
||||
for (const row of rows) {
|
||||
const hhmm = hhmmInTz(row.scheduled_for, tz);
|
||||
if (!HHMM_RE.test(hhmm)) continue;
|
||||
if (Number(hhmm.slice(3)) % 5 !== 0) continue; // not anchor-shaped
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
let set = dayTimes.get(dayKey);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
dayTimes.set(dayKey, set);
|
||||
dayOrder.push(dayKey);
|
||||
}
|
||||
set.add(hhmm);
|
||||
}
|
||||
|
||||
// The leading run of recorded days whose anchor times ALL deviate
|
||||
// from the current schedule, ended by the first day that matches
|
||||
// the current times. Consecutive = consecutive RECORDED days; gaps
|
||||
// without rows (skipped logging) do not break the run.
|
||||
const offTimes = new Set<string>();
|
||||
let runDays = 0;
|
||||
let firstOffDay: string | null = null;
|
||||
let switchDay: string | null = null;
|
||||
for (const dayKey of dayOrder) {
|
||||
const times = [...(dayTimes.get(dayKey) ?? [])];
|
||||
if (times.length === 0) continue;
|
||||
const allOff = times.every((t) => !anchors.has(t));
|
||||
const anyOn = times.some((t) => anchors.has(t));
|
||||
if (allOff) {
|
||||
if (switchDay !== null) {
|
||||
// Off-anchor days AFTER current times appeared — mixed
|
||||
// history, not a clean era prefix. Bail for this medication.
|
||||
runDays = 0;
|
||||
break;
|
||||
}
|
||||
runDays += 1;
|
||||
if (firstOffDay === null) firstOffDay = dayKey;
|
||||
for (const t of times) offTimes.add(t);
|
||||
} else if (anyOn && switchDay === null) {
|
||||
switchDay = dayKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (runDays >= 7 && firstOffDay !== null && switchDay !== null) {
|
||||
const firstRow = rows.find(
|
||||
(r) => dayKeyForScheduledFor(r.scheduled_for, tz) === firstOffDay,
|
||||
);
|
||||
const validFrom = firstRow?.scheduled_for ?? rows[0].created_at;
|
||||
const validUntil = startOfDayUtcInTz(switchDay, safeTimezone(tz));
|
||||
eraProposals.push({
|
||||
userId: rows[0].user_id,
|
||||
medicationId,
|
||||
medicationName: rows[0].medication_name,
|
||||
timesOfDay: [...offTimes].sort(),
|
||||
validFrom,
|
||||
validUntil,
|
||||
dayCount: runDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
summary.eraCandidates = eraProposals.length;
|
||||
summary.startsOnCandidates = startsOnProposals.length;
|
||||
console.log(
|
||||
`\n${TAG} 5) era-inference candidates (>=7 consistent off-anchor days): ${eraProposals.length}`,
|
||||
);
|
||||
if (eraProposals.length > 0) {
|
||||
console.table(
|
||||
eraProposals.map((p) => ({
|
||||
medication: p.medicationName,
|
||||
oldTimes: p.timesOfDay.join(","),
|
||||
validFrom: p.validFrom.toISOString(),
|
||||
validUntil: p.validUntil.toISOString(),
|
||||
days: p.dayCount,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (startsOnProposals.length > 0) {
|
||||
console.log(
|
||||
`${TAG} startsOn postdates the first recorded row: ${startsOnProposals.length}`,
|
||||
);
|
||||
console.table(
|
||||
startsOnProposals.map((p) => ({
|
||||
medication: p.medicationName,
|
||||
startsOn: p.startsOn.toISOString(),
|
||||
firstRow: p.firstRow.toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.backfillEras) {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
for (const p of eraProposals) {
|
||||
const payload = JSON.stringify([
|
||||
{
|
||||
timesOfDay: p.timesOfDay,
|
||||
windowStart: p.timesOfDay[0],
|
||||
windowEnd: p.timesOfDay[p.timesOfDay.length - 1],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
},
|
||||
]);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO "medication_schedule_revisions"
|
||||
("id", "medication_id", "valid_from", "valid_until", "payload")
|
||||
SELECT $1, $2, $3::timestamptz, $4::timestamptz, $5::jsonb
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "medication_schedule_revisions"
|
||||
WHERE "medication_id" = $2
|
||||
)
|
||||
`,
|
||||
[
|
||||
randomUUID(),
|
||||
p.medicationId,
|
||||
p.validFrom.toISOString(),
|
||||
p.validUntil.toISOString(),
|
||||
payload,
|
||||
],
|
||||
);
|
||||
summary.eraRevisionsCreated += 1;
|
||||
}
|
||||
for (const p of startsOnProposals) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medications"
|
||||
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
|
||||
WHERE "id" = $1 AND "starts_on" = $3::timestamp
|
||||
`,
|
||||
[
|
||||
p.medicationId,
|
||||
utcParam(p.firstRow),
|
||||
utcParam(p.startsOn),
|
||||
],
|
||||
);
|
||||
summary.startsOnAdjusted += res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 6. Recompute the affected compliance rollups so scheduled/taken
|
||||
// counts self-correct. Same DISTINCT-slot SQL the shared helper
|
||||
// runs (see `recomputeComplianceDay` above).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (opts.fix && daysToRecompute.size > 0) {
|
||||
console.log(
|
||||
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
`\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
);
|
||||
for (const key of daysToRecompute) {
|
||||
const [userId, medicationId, dayKey] = key.split("|");
|
||||
@@ -850,6 +1110,10 @@ async function main(): Promise<void> {
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(` era-inference candidates: ${summary.eraCandidates}`);
|
||||
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
|
||||
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
|
||||
console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`);
|
||||
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -261,7 +261,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
|
||||
@@ -1007,7 +1007,11 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationSchedule: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationScheduleRevision: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
@@ -101,6 +106,15 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
// v1.16.3 — the schedule-replace branch archives the previous rows as a
|
||||
// revision when a cadence field changes; default to "no previous rows".
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -202,6 +216,125 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace archives a revision (v1.16.3)", () => {
|
||||
const PREVIOUS_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
PREVIOUS_ROW,
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("archives the previous rows when the dose times change", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
const data = calls[0][0].data as {
|
||||
medicationId: string;
|
||||
validFrom: Date;
|
||||
validUntil: Date;
|
||||
payload: Array<{ timesOfDay: string[] }>;
|
||||
};
|
||||
expect(data.medicationId).toBe("m1");
|
||||
// First revision chains from medication.createdAt.
|
||||
expect(data.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
|
||||
expect(data.validUntil.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
expect(data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
});
|
||||
|
||||
it("does NOT archive a revision on a no-op echo of the same schedule", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
timesOfDay: ["19:00", "07:00"], // reordered — still no-op
|
||||
rrule: "FREQ=DAILY",
|
||||
label: "Renamed", // display-only — never a cadence change
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chains validFrom from the newest existing revision", async () => {
|
||||
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({
|
||||
validUntil: new Date("2026-06-01T12:00:00.000Z"),
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "10:00",
|
||||
windowEnd: "22:00",
|
||||
timesOfDay: ["10:00", "22:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
const data = calls[0][0].data as { validFrom: Date };
|
||||
expect(data.validFrom.toISOString()).toBe("2026-06-01T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -57,7 +57,11 @@ export const GET = apiHandler(
|
||||
where: { id },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware chips/tallies.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -126,6 +130,7 @@ export const GET = apiHandler(
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
timeZone: userTz,
|
||||
scheduleRevisions: med.scheduleRevisions,
|
||||
};
|
||||
|
||||
const timeline = buildCadenceTimeline(
|
||||
|
||||
@@ -246,7 +246,11 @@ export const GET = apiHandler(
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
|
||||
@@ -29,10 +29,10 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandFamily,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
@@ -123,7 +123,12 @@ export const GET = apiHandler(
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras: past days mint against the
|
||||
// schedule that was live THEN.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -205,9 +210,10 @@ export const GET = apiHandler(
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication: bandMedication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from, to },
|
||||
|
||||
@@ -17,7 +17,15 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
schedulesMateriallyDiffer,
|
||||
toRevisionPayloadEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
@@ -113,7 +121,9 @@ export const GET = apiHandler(
|
||||
// slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the
|
||||
// slots adjacent to now.
|
||||
const now = new Date();
|
||||
const resolvedFrom = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
// v1.16.4 — reach back as far as the widest band tail so a
|
||||
// long-resolved past slot can never resurface as "overdue".
|
||||
const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
|
||||
const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const resolvedRows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
@@ -129,7 +139,15 @@ export const GET = apiHandler(
|
||||
},
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
const nextDue = computeNextDueAt({
|
||||
// v1.16.4 — current-era floor: the open-overdue search mints from
|
||||
// the LIVE schedule rows, so it must not reach past the newest
|
||||
// revision boundary into a previous era's cadence.
|
||||
const latestRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
@@ -142,6 +160,7 @@ export const GET = apiHandler(
|
||||
userTz: user.timezone || "Europe/Berlin",
|
||||
lastIntakeAt: lastIntake?.takenAt ?? null,
|
||||
resolvedSlots: resolvedRows.map((r) => r.scheduledFor),
|
||||
eraStart: latestRevision?.validUntil ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -155,7 +174,8 @@ export const GET = apiHandler(
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
category,
|
||||
nextDueAt: nextDue ? nextDue.toISOString() : null,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -174,7 +194,7 @@ export const PUT = apiHandler(
|
||||
if (guard) return guard;
|
||||
const existing = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { active: true },
|
||||
select: { active: true, createdAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -280,8 +300,151 @@ export const PUT = apiHandler(
|
||||
? { pausedAt: new Date() }
|
||||
: {};
|
||||
|
||||
// v1.16.3 — normalise the incoming schedules ONCE: the same values feed
|
||||
// the create payload below AND the revision compare/snapshot (so the
|
||||
// material-change gate sees exactly what will be persisted).
|
||||
const normalisedSchedules = schedules?.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// v1.7.0 — PRN carries no cadence, so never default it.
|
||||
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
||||
const isPrn = s.scheduleType === "PRN";
|
||||
const defaultedRrule =
|
||||
oneShot !== true &&
|
||||
!isPrn &&
|
||||
s.rrule === undefined &&
|
||||
s.rollingIntervalDays === undefined &&
|
||||
!hasLegacyDays
|
||||
? "FREQ=DAILY"
|
||||
: s.rrule;
|
||||
|
||||
// Invariant 3 — dual-write timesOfDay from windowStart.
|
||||
const effectiveTimesOfDay =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
const serializedDaysOfWeek = serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
});
|
||||
|
||||
return {
|
||||
createData: {
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
// v1.5 first-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
|
||||
...(s.scheduleType !== undefined && {
|
||||
scheduleType: s.scheduleType,
|
||||
}),
|
||||
...(s.cyclicOnWeeks !== undefined && {
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
}),
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && {
|
||||
doseWindows: s.doseWindows,
|
||||
}),
|
||||
},
|
||||
// Mirror of the row the create above will persist (defaults applied),
|
||||
// shaped for the cadence compare.
|
||||
snapshot: toRevisionPayloadEntry({
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
rrule: defaultedRrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: s.doseWindows ?? null,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules) {
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
)
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: previousEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: previousEntries.length },
|
||||
});
|
||||
}
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -366,74 +529,9 @@ export const PUT = apiHandler(
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
...(schedules && {
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: schedules.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// v1.7.0 — PRN carries no cadence, so never default it.
|
||||
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
||||
const isPrn = s.scheduleType === "PRN";
|
||||
const defaultedRrule =
|
||||
oneShot !== true &&
|
||||
!isPrn &&
|
||||
s.rrule === undefined &&
|
||||
s.rollingIntervalDays === undefined &&
|
||||
!hasLegacyDays
|
||||
? "FREQ=DAILY"
|
||||
: s.rrule;
|
||||
|
||||
// Invariant 3 — dual-write timesOfDay from windowStart.
|
||||
const effectiveTimesOfDay =
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay
|
||||
: [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
return {
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
}),
|
||||
// v1.5 first-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
|
||||
...(s.scheduleType !== undefined && {
|
||||
scheduleType: s.scheduleType,
|
||||
}),
|
||||
...(s.cyclicOnWeeks !== undefined && {
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
}),
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && {
|
||||
doseWindows: s.doseWindows,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -258,7 +258,11 @@ async function buildScheduleAnchoredComplianceBuckets(
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
@@ -41,8 +44,15 @@ async function buildMedicationsList(
|
||||
const resolvedWindowEnd = new Date(
|
||||
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
// v1.16.4 — the open-overdue search reaches back as far as the widest
|
||||
// band tail (weekly on-time + overdue), so the resolved-slot read must
|
||||
// cover the same horizon or a long-resolved past slot would resurface
|
||||
// as "overdue".
|
||||
const resolvedWindowStart = new Date(
|
||||
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
||||
);
|
||||
|
||||
const [medications, latestIntakes, todayEvents, resolvedEvents] =
|
||||
const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] =
|
||||
await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
@@ -74,7 +84,7 @@ async function buildMedicationsList(
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStartUtc, lte: resolvedWindowEnd },
|
||||
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
|
||||
OR: [
|
||||
{ takenAt: { not: null } },
|
||||
{ skipped: true },
|
||||
@@ -83,6 +93,15 @@ async function buildMedicationsList(
|
||||
},
|
||||
select: { medicationId: true, scheduledFor: true },
|
||||
}),
|
||||
// v1.16.4 — current-era floor per medication: the newest revision's
|
||||
// `validUntil` is where the LIVE schedule rows became valid. The
|
||||
// open-overdue search mints from the live rows, so it must not reach
|
||||
// past this boundary into a previous era's cadence.
|
||||
prisma.medicationScheduleRevision.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { medication: { userId } },
|
||||
_max: { validUntil: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolvedSlotsByMedId = new Map<string, Date[]>();
|
||||
@@ -92,6 +111,11 @@ async function buildMedicationsList(
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
|
||||
}
|
||||
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
|
||||
const lastTakenAtByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [
|
||||
entry.medicationId,
|
||||
@@ -125,7 +149,12 @@ async function buildMedicationsList(
|
||||
const now = new Date();
|
||||
|
||||
return medications.map((m) => {
|
||||
const nextDue = computeNextDueAt({
|
||||
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
||||
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
||||
// true`; only a closed or resolved band falls through to the future
|
||||
// next-due. Keeps the card on the still-takeable dose instead of
|
||||
// jumping ahead the minute the anchor passes.
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
@@ -138,13 +167,15 @@ async function buildMedicationsList(
|
||||
userTz,
|
||||
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
|
||||
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
||||
eraStart: eraStartByMedId.get(m.id) ?? null,
|
||||
});
|
||||
return {
|
||||
...m,
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
nextDueAt: nextDue ? nextDue.toISOString() : null,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
@@ -1135,20 +1136,7 @@ export function HealthChart({
|
||||
.filter((value): value is number => typeof value === "number")
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (!values.length) return undefined;
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
if (min === max) {
|
||||
const delta = Math.max(Math.abs(min) * 0.05, 1);
|
||||
return [min - delta, max + delta * 1.35];
|
||||
}
|
||||
|
||||
const span = max - min;
|
||||
const paddingBottom = Math.max(span * 0.08, 0.5);
|
||||
const paddingTop = Math.max(span * 0.16, 1);
|
||||
return [min - paddingBottom, max + paddingTop];
|
||||
return computePaddedYDomain(values);
|
||||
}, [
|
||||
chartDataWithCompare,
|
||||
effectiveCompareBaseline,
|
||||
|
||||
@@ -157,18 +157,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* "About HealthLog" links the signed-in `/settings/about`
|
||||
section (version, licences, update check). The slug also has
|
||||
its own entry at the end of the settings shell nav; this
|
||||
dropdown item keeps the historic discovery path alive. The
|
||||
public `/about` landing stays separate for signed-out
|
||||
visitors. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/about" className="cursor-pointer">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
{t("nav.about")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* The about section lives at the end of the settings shell nav;
|
||||
the avatar menu stays focused on account-level actions. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{themeIcon}
|
||||
|
||||
@@ -213,3 +213,76 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
|
||||
expect(html).toContain("Tomorrow,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
// Server contract: when an unresolved slot's anchor has passed but the
|
||||
// catch-up band is still open, the list GET carries THAT slot in
|
||||
// `nextDueAt` with `nextDueOverdue: true`. The card must render it as a
|
||||
// calm amber "overdue — still takeable" line instead of jumping to the
|
||||
// next future slot; with the flag false the regular phrasing returns.
|
||||
function makeMed(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "med-overdue-1",
|
||||
name: "Metformin",
|
||||
dose: "500 mg",
|
||||
category: "OTHER",
|
||||
treatmentClass: undefined as string | undefined,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
schedules: [{ id: "s-overdue-1", ...pastWindow }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders the overdue slot as an amber still-takeable line, not a future next-intake", () => {
|
||||
// Pinned now is 2026-06-02T12:00:00Z; the slot anchor sits two hours
|
||||
// earlier, still inside its catch-up band per the server flag.
|
||||
const med = makeMed({
|
||||
nextDueAt: "2026-06-02T10:00:00Z",
|
||||
nextDueOverdue: true,
|
||||
});
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med.id);
|
||||
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Overdue ·");
|
||||
expect(html).toContain("can still be taken");
|
||||
expect(html).toContain("text-amber-600");
|
||||
expect(html).not.toContain("Next intake: Tomorrow,");
|
||||
});
|
||||
|
||||
it("renders the regular upcoming phrasing once the flag is false (band closed, next slot)", () => {
|
||||
const due = new Date("2026-06-03T10:00:00Z"); // tomorrow relative to pinned now
|
||||
const med = makeMed({
|
||||
nextDueAt: due.toISOString(),
|
||||
nextDueOverdue: false,
|
||||
});
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med.id);
|
||||
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Next intake:");
|
||||
expect(html).toContain("Tomorrow");
|
||||
expect(html).not.toContain("Overdue ·");
|
||||
expect(html).not.toContain("can still be taken");
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,12 @@ export interface Glp1Medication {
|
||||
* walked a single hardcoded weekly day-of-week.
|
||||
*/
|
||||
nextDueAt?: string | null;
|
||||
/**
|
||||
* v1.16.4 — true when `nextDueAt` is an OPEN overdue slot (anchor
|
||||
* passed, "now" still inside its catch-up band, unresolved). The card
|
||||
* renders it as "overdue — still takeable" instead of a future date.
|
||||
*/
|
||||
nextDueOverdue?: boolean;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -376,17 +382,35 @@ export function Glp1MedicationCard({
|
||||
// the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while
|
||||
// the structure / labels live in the shared body. The purple dose accent
|
||||
// is byte-equivalent with the generic card's.
|
||||
// v1.16.4 — an open overdue slot stays on the card as a calm amber
|
||||
// "overdue — still takeable" line until its catch-up band closes
|
||||
// (auto-miss); only then does the line advance to the next future slot.
|
||||
// A same-day overdue anchor reads as a clock time, an older one (the
|
||||
// weekly catch-up tail spans days) as its weekday + date.
|
||||
const overdueLabel =
|
||||
medication.nextDueOverdue && next
|
||||
? diffDays(next.date, now) === 0
|
||||
? formatTime(next.date.toISOString())
|
||||
: `${weekdayLabel(next.date.getDay())}, ${fmt.dateShort(next.date)}`
|
||||
: null;
|
||||
|
||||
const nextLine =
|
||||
next && currentWindowStatus.status !== "in_window" ? (
|
||||
<>
|
||||
{nextInjectionLabel()}
|
||||
{schedule?.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
overdueLabel ? (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("medications.nextIntakeOverdue", { time: overdueLabel })}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{nextInjectionLabel()}
|
||||
{schedule?.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : null;
|
||||
|
||||
// v1.15.9 — the last-injection line drops the injection-site display; it
|
||||
|
||||
@@ -76,6 +76,13 @@ interface Medication {
|
||||
* cadences that the legacy daysOfWeek-only walker ignored.
|
||||
*/
|
||||
nextDueAt?: string | null;
|
||||
/**
|
||||
* v1.16.4 — true when `nextDueAt` is an OPEN overdue slot (its anchor
|
||||
* passed but "now" is still inside the slot's catch-up band and no
|
||||
* intake row resolves it). The card renders the slot as "overdue —
|
||||
* still takeable" instead of the regular upcoming-intake phrasing.
|
||||
*/
|
||||
nextDueOverdue?: boolean;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -300,6 +307,20 @@ export function MedicationCard({
|
||||
? (() => {
|
||||
const s = nextSchedule;
|
||||
|
||||
// v1.16.4 — an open overdue slot stays on the card as a calm
|
||||
// amber "overdue · HH:mm — still takeable" line until its
|
||||
// catch-up band closes (auto-miss); only then does the line
|
||||
// advance to the next future slot.
|
||||
if (medication.nextDueOverdue && nextAt) {
|
||||
return (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("medications.nextIntakeOverdue", {
|
||||
time: formatTime(new Date(nextAt).toISOString()),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Format day label relative to today
|
||||
let dayLabel = "";
|
||||
if (nextAt) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeTooltipPosition } from "../tour";
|
||||
|
||||
/**
|
||||
* Pure-function contract tests for the tour popover position resolver.
|
||||
*
|
||||
* Production regression (v1.16.2): step 4 anchored to a nav item below
|
||||
* the fold and the resolved position put the whole card under the
|
||||
* viewport bottom — the Next button was unreachable and the tour was
|
||||
* stuck. The resolver now guarantees: every anchored placement keeps
|
||||
* the card's bottom edge ≥ 8 px above the viewport bottom, vertical
|
||||
* fallbacks prefer ABOVE the target, and an off-screen target (or a
|
||||
* target with no fitting placement at all) resolves to the `"sheet"`
|
||||
* fallback on every viewport width.
|
||||
*/
|
||||
|
||||
const VIEWPORT = { width: 1280, height: 800 };
|
||||
const SIZE = { width: 320, height: 220 };
|
||||
const PAD = 8;
|
||||
|
||||
function expectInsideViewport(pos: { top: number; left: number }) {
|
||||
expect(pos.top).toBeGreaterThanOrEqual(PAD);
|
||||
expect(pos.left).toBeGreaterThanOrEqual(PAD);
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(VIEWPORT.height - PAD);
|
||||
expect(pos.left + SIZE.width).toBeLessThanOrEqual(VIEWPORT.width - PAD);
|
||||
}
|
||||
|
||||
describe("computeTooltipPosition", () => {
|
||||
it("returns a centred placement when there is no target rect", () => {
|
||||
const pos = computeTooltipPosition(null, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("center");
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("keeps the requested bottom placement when it fits", () => {
|
||||
const rect = { top: 100, left: 400, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("bottom");
|
||||
expect(pos.top).toBeGreaterThan(rect.top + rect.height);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("flips a bottom placement ABOVE a target low in the viewport", () => {
|
||||
// Target visible but near the bottom edge: below it there is no
|
||||
// room for a 220 px card, above it there is.
|
||||
const rect = { top: 700, left: 400, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("top");
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(rect.top);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when the target sits below the fold", () => {
|
||||
// The production bug: an anchor entirely under the viewport bottom.
|
||||
const rect = { top: 900, left: 16, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "right", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when the target sits above the viewport", () => {
|
||||
const rect = { top: -300, left: 16, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("falls back from a side placement to the vertical axis when neither side fits", () => {
|
||||
// Target spans nearly the full width: no room left or right, but
|
||||
// room above — the resolver must not clamp the card over the
|
||||
// target or below the fold.
|
||||
const rect = { top: 600, left: 8, width: 1264, height: 60 };
|
||||
const pos = computeTooltipPosition(rect, "right", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("top");
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(rect.top);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when no candidate fits anywhere", () => {
|
||||
// Target covers essentially the whole viewport — every anchored
|
||||
// candidate would land outside.
|
||||
const rect = { top: 8, left: 8, width: 1264, height: 784 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("keeps the card bottom edge ≥ 8px above the viewport bottom for every anchored result", () => {
|
||||
const placements = ["top", "bottom", "left", "right", "center"] as const;
|
||||
for (const placement of placements) {
|
||||
for (let top = -200; top <= 1000; top += 40) {
|
||||
const rect = { top, left: 480, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, placement, SIZE, VIEWPORT);
|
||||
if (pos.placement === "sheet") continue; // sheet pins itself via CSS
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(
|
||||
VIEWPORT.height - PAD,
|
||||
);
|
||||
expect(pos.top).toBeGreaterThanOrEqual(PAD);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,21 +99,30 @@ function measureTarget(targetId: string | null): SpotlightRect | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved placement — the geometric placements plus the sheet fallback. */
|
||||
export type ResolvedPlacement = TourStop["placement"] | "sheet";
|
||||
|
||||
/**
|
||||
* Decide tooltip position. The intent is to sit beside the target
|
||||
* along the requested axis, but flip to the opposite edge if there
|
||||
* isn't enough room. Returns absolute viewport coordinates that the
|
||||
* UI uses with `position: fixed`. Falls back to centred when there
|
||||
* is no spotlight rect.
|
||||
* isn't enough room — vertical fallbacks prefer ABOVE the target so
|
||||
* the footer buttons can never be pushed under the viewport bottom.
|
||||
* Returns absolute viewport coordinates that the UI uses with
|
||||
* `position: fixed`. Falls back to centred when there is no spotlight
|
||||
* rect, and to `"sheet"` (caller renders a bottom sheet on EVERY
|
||||
* viewport width) when the target is off-screen or no candidate fits.
|
||||
*
|
||||
* Pure function of its inputs (viewport passed explicitly) so the
|
||||
* Node-environment vitest suite can exercise it without jsdom.
|
||||
*/
|
||||
function computeTooltipPosition(
|
||||
export function computeTooltipPosition(
|
||||
rect: SpotlightRect | null,
|
||||
placement: TourStop["placement"],
|
||||
tooltipSize: { width: number; height: number },
|
||||
): { top: number; left: number; placement: TourStop["placement"] } {
|
||||
if (typeof window === "undefined") return { top: 0, left: 0, placement };
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
viewport: { width: number; height: number },
|
||||
): { top: number; left: number; placement: ResolvedPlacement } {
|
||||
const vw = viewport.width;
|
||||
const vh = viewport.height;
|
||||
if (!rect) {
|
||||
return {
|
||||
top: Math.max(16, (vh - tooltipSize.height) / 2),
|
||||
@@ -121,6 +130,18 @@ function computeTooltipPosition(
|
||||
placement: "center",
|
||||
};
|
||||
}
|
||||
// Target entirely outside the visible viewport (e.g. an anchor below
|
||||
// the fold that scrollIntoView could not bring in, or mid-scroll):
|
||||
// every anchored candidate would point at nothing and risk landing
|
||||
// off-screen. Render the sheet fallback instead — on ALL viewports.
|
||||
const targetVisible =
|
||||
rect.top < vh - COLLISION_PAD &&
|
||||
rect.top + rect.height > COLLISION_PAD &&
|
||||
rect.left < vw - COLLISION_PAD &&
|
||||
rect.left + rect.width > COLLISION_PAD;
|
||||
if (!targetVisible) {
|
||||
return { top: 0, left: 0, placement: "sheet" };
|
||||
}
|
||||
const GAP = 12;
|
||||
const candidates: Array<{
|
||||
placement: TourStop["placement"];
|
||||
@@ -128,61 +149,52 @@ function computeTooltipPosition(
|
||||
left: number;
|
||||
}> = [];
|
||||
|
||||
if (placement === "bottom" || placement === "top" || placement === "center") {
|
||||
candidates.push({
|
||||
placement: "bottom",
|
||||
top: rect.top + rect.height + GAP,
|
||||
left: Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
),
|
||||
});
|
||||
candidates.push({
|
||||
placement: "top",
|
||||
top: rect.top - tooltipSize.height - GAP,
|
||||
left: Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (placement === "right" || placement === "left") {
|
||||
candidates.push({
|
||||
placement: "right",
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left: rect.left + rect.width + GAP,
|
||||
});
|
||||
candidates.push({
|
||||
placement: "left",
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left: rect.left - tooltipSize.width - GAP,
|
||||
});
|
||||
}
|
||||
// Pick the first candidate that fits the viewport; otherwise fall back to
|
||||
// centred. EVERY returned position is clamped into the viewport with a
|
||||
// collision padding — a candidate computed off a target near the edge must
|
||||
// never push the card (and its footer buttons) out of view.
|
||||
const clamp = (c: {
|
||||
placement: TourStop["placement"];
|
||||
top: number;
|
||||
left: number;
|
||||
}) => ({
|
||||
placement: c.placement,
|
||||
top: Math.min(
|
||||
Math.max(COLLISION_PAD, c.top),
|
||||
Math.max(COLLISION_PAD, vh - tooltipSize.height - COLLISION_PAD),
|
||||
),
|
||||
left: Math.min(
|
||||
Math.max(COLLISION_PAD, c.left),
|
||||
Math.max(COLLISION_PAD, vw - tooltipSize.width - COLLISION_PAD),
|
||||
),
|
||||
const centredLeft = Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
);
|
||||
const vertical = (side: "top" | "bottom") => ({
|
||||
placement: side,
|
||||
top:
|
||||
side === "bottom"
|
||||
? rect.top + rect.height + GAP
|
||||
: rect.top - tooltipSize.height - GAP,
|
||||
left: centredLeft,
|
||||
});
|
||||
const horizontal = (side: "left" | "right") => ({
|
||||
placement: side,
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left:
|
||||
side === "right"
|
||||
? rect.left + rect.width + GAP
|
||||
: rect.left - tooltipSize.width - GAP,
|
||||
});
|
||||
|
||||
if (placement === "bottom" || placement === "center") {
|
||||
candidates.push(vertical("bottom"), vertical("top"));
|
||||
} else if (placement === "top") {
|
||||
candidates.push(vertical("top"), vertical("bottom"));
|
||||
} else {
|
||||
// Side placements: requested side first, then the opposite side,
|
||||
// then the vertical axis — above before below, so a target low in
|
||||
// the viewport flips the card upwards instead of clipping its
|
||||
// footer under the fold.
|
||||
candidates.push(
|
||||
horizontal(placement),
|
||||
horizontal(placement === "right" ? "left" : "right"),
|
||||
vertical("top"),
|
||||
vertical("bottom"),
|
||||
);
|
||||
}
|
||||
// Pick the first candidate that fully fits the viewport — by
|
||||
// construction its bottom edge sits ≥ COLLISION_PAD above the
|
||||
// viewport bottom. If NONE fits (target visible but crowded on every
|
||||
// side), fall back to the sheet rather than clamping a card over an
|
||||
// arbitrary region: the sheet keeps the footer buttons reachable on
|
||||
// every viewport, which is the property the tour cannot lose.
|
||||
for (const c of candidates) {
|
||||
if (
|
||||
c.top >= COLLISION_PAD &&
|
||||
@@ -193,14 +205,7 @@ function computeTooltipPosition(
|
||||
return c;
|
||||
}
|
||||
}
|
||||
// None fits as-is: prefer the first requested candidate clamped into the
|
||||
// viewport (keeps the card near its target) over jumping to centre.
|
||||
if (candidates.length > 0) return clamp(candidates[0]);
|
||||
return clamp({
|
||||
placement: "center",
|
||||
top: (vh - tooltipSize.height) / 2,
|
||||
left: (vw - tooltipSize.width) / 2,
|
||||
});
|
||||
return { top: 0, left: 0, placement: "sheet" };
|
||||
}
|
||||
|
||||
const TOOLTIP_WIDTH = 320;
|
||||
@@ -237,6 +242,21 @@ export function OnboardingTour({
|
||||
setRect(measureTarget(stop.targetId));
|
||||
});
|
||||
};
|
||||
// Bring the target into view BEFORE the first measurement of this
|
||||
// step — anchors low on the page (e.g. the Settings nav item) can
|
||||
// sit entirely below the fold, and positioning against an
|
||||
// off-screen rect put the popover under the viewport bottom where
|
||||
// its Next button was unreachable. `block: "center"` keeps room on
|
||||
// both sides for the flipped placement; the rAF in `measure()`
|
||||
// runs after the instant scroll so we measure post-scroll
|
||||
// coordinates. Optional-chained: jsdom and older engines lack it.
|
||||
if (stop.targetId && typeof document !== "undefined") {
|
||||
document
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-tour-id="${CSS.escape(stop.targetId)}"]`,
|
||||
)
|
||||
?.scrollIntoView?.({ block: "center", inline: "nearest" });
|
||||
}
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("scroll", measure, true);
|
||||
@@ -362,15 +382,23 @@ export function OnboardingTour({
|
||||
// next to a target on a ~400 px screen always ends up clipped.
|
||||
const viewportWidth =
|
||||
typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
const asSheet = viewportWidth < SHEET_BREAKPOINT;
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 768;
|
||||
const tooltipWidth = Math.min(
|
||||
TOOLTIP_WIDTH,
|
||||
viewportWidth - COLLISION_PAD * 2,
|
||||
);
|
||||
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
|
||||
width: tooltipWidth,
|
||||
height: TOOLTIP_HEIGHT,
|
||||
});
|
||||
const tooltipPos = computeTooltipPosition(
|
||||
rect,
|
||||
stop.placement,
|
||||
{ width: tooltipWidth, height: TOOLTIP_HEIGHT },
|
||||
{ width: viewportWidth, height: viewportHeight },
|
||||
);
|
||||
// The sheet renders on narrow viewports unconditionally AND on any
|
||||
// viewport when the resolver found no anchored placement that keeps
|
||||
// the whole card (footer included) inside the viewport.
|
||||
const asSheet =
|
||||
viewportWidth < SHEET_BREAKPOINT || tooltipPos.placement === "sheet";
|
||||
|
||||
// v1.4.33 F2 — onboarding overlay was a single full-viewport `<button>`
|
||||
// with a `clip-path` punching a hole around the spotlight. The clip-path
|
||||
@@ -518,11 +546,12 @@ export function OnboardingTour({
|
||||
data-placement={asSheet ? "sheet" : tooltipPos.placement}
|
||||
className={
|
||||
asSheet
|
||||
? // Bottom-sheet fallback for small viewports — full-width card
|
||||
// pinned above the bottom edge; no anchored positioning that
|
||||
// could clip the footer buttons off-screen.
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 max-h-[70vh] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
: "bg-card border-border pointer-events-auto absolute max-h-[80vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
? // Bottom-sheet fallback — full-width on small screens,
|
||||
// centred above the bottom edge on wide ones (`mx-auto` +
|
||||
// `max-w`). Used on EVERY viewport when no anchored
|
||||
// placement keeps the footer buttons inside the viewport.
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
}
|
||||
style={
|
||||
asSheet
|
||||
@@ -531,6 +560,15 @@ export function OnboardingTour({
|
||||
top: `${tooltipPos.top}px`,
|
||||
left: `${tooltipPos.left}px`,
|
||||
width: `${tooltipWidth}px`,
|
||||
// Hard guarantee: the card's bottom edge stays ≥
|
||||
// COLLISION_PAD above the viewport bottom even when the
|
||||
// real content outgrows the TOOLTIP_HEIGHT estimate the
|
||||
// resolver positioned with — the card scrolls internally
|
||||
// instead of pushing its footer below the fold.
|
||||
maxHeight: `${Math.max(
|
||||
0,
|
||||
viewportHeight - tooltipPos.top - COLLISION_PAD,
|
||||
)}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
occurrencesAcrossEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
type CadenceEngineContext,
|
||||
@@ -419,6 +423,13 @@ export interface ComplianceMedicationContext {
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
timeZone: string;
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present,
|
||||
* every expected-slot expansion + band mint segments its range so a past
|
||||
* day counts/mints against the schedule that was live THEN. Optional:
|
||||
* callers without revisions keep the live-only expansion.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,6 +444,8 @@ export function buildComplianceMedicationContext(
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
/** v1.16.3 — thread the archived eras when the bundle carries them. */
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
},
|
||||
lastIntakeAt: Date | null,
|
||||
timeZone: string,
|
||||
@@ -444,6 +457,7 @@ export function buildComplianceMedicationContext(
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
timeZone,
|
||||
...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,25 +638,28 @@ export function expectedSlotCountForDay(
|
||||
ctx: ComplianceMedicationContext,
|
||||
intakes?: ComplianceIntakeInstant[],
|
||||
): number {
|
||||
let count = 0;
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
|
||||
const now = new Date();
|
||||
const retro =
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now }
|
||||
: undefined;
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
count += expandComplianceOccurrences(
|
||||
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
|
||||
recurrenceCtx,
|
||||
dayStart,
|
||||
// v1.16.3 — era-aware: a past day counts the slots of the schedule that
|
||||
// was live THEN. With no revisions the single live era expands exactly
|
||||
// the per-schedule loop this used to run.
|
||||
return occurrencesAcrossEras(
|
||||
{
|
||||
from: dayStart,
|
||||
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
|
||||
// slot exactly at the next day's midnight doesn't double-count.
|
||||
new Date(dayEnd.getTime() - 1),
|
||||
retro,
|
||||
).length;
|
||||
}
|
||||
return count;
|
||||
to: new Date(dayEnd.getTime() - 1),
|
||||
},
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
{ oneShot: ctx.oneShot },
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,19 +703,17 @@ export function expectedSlotsBetween(
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to }
|
||||
: undefined;
|
||||
const all: Occurrence[] = [];
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
all.push(
|
||||
...expandComplianceOccurrences(
|
||||
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
|
||||
recurrenceCtx,
|
||||
effectiveFrom,
|
||||
to,
|
||||
retro,
|
||||
),
|
||||
);
|
||||
}
|
||||
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
if (effectiveFrom.getTime() > to.getTime()) return [];
|
||||
// v1.16.3 — era-aware: each archived era contributes the slots of ITS
|
||||
// schedules; the live rows cover only the range past the newest revision.
|
||||
return occurrencesAcrossEras(
|
||||
{ from: effectiveFrom, to },
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
{ oneShot: ctx.oneShot },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,9 +1276,11 @@ export function buildComplianceLedgerRows(
|
||||
to,
|
||||
);
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
// v1.16.3 — era-aware mint: archived eras band with THEIR schedules.
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: ctx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz: ctx.timeZone,
|
||||
range: { from, to },
|
||||
@@ -1504,6 +1521,7 @@ export function calculateCompliance(
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1853,6 +1871,7 @@ function buildTimelineForWindow(
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
};
|
||||
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
|
||||
describe("computePaddedYDomain", () => {
|
||||
it("returns undefined for an empty series", () => {
|
||||
expect(computePaddedYDomain([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-finite values", () => {
|
||||
expect(computePaddedYDomain([NaN, Infinity, -Infinity])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps the lower bound to 0 for a non-negative series", () => {
|
||||
const domain = computePaddedYDomain([200, 450, 900]);
|
||||
expect(domain).toBeDefined();
|
||||
const [min, max] = domain!;
|
||||
expect(min).toBeGreaterThanOrEqual(0);
|
||||
expect(max).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("clamps to 0 when bottom padding would dip below 0 (steps-like series)", () => {
|
||||
// span 8800 → 8% bottom padding = 704 > min 200 → unclamped would be -504
|
||||
const domain = computePaddedYDomain([200, 9000]);
|
||||
expect(domain).toBeDefined();
|
||||
expect(domain![0]).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps downward padding for a series with genuine negative values", () => {
|
||||
const domain = computePaddedYDomain([-5, 3, 10]);
|
||||
expect(domain).toBeDefined();
|
||||
const [min, max] = domain!;
|
||||
expect(min).toBeLessThan(-5);
|
||||
expect(max).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("pads a positive series without touching 0 when padding stays above it", () => {
|
||||
// span 2 → bottom padding max(0.16, 0.5) = 0.5 → 79.5, no clamp needed
|
||||
const domain = computePaddedYDomain([80, 82]);
|
||||
expect(domain).toBeDefined();
|
||||
expect(domain![0]).toBeCloseTo(79.5);
|
||||
});
|
||||
|
||||
it("clamps a flat zero series to a [0, x] domain", () => {
|
||||
const domain = computePaddedYDomain([0, 0, 0]);
|
||||
expect(domain).toBeDefined();
|
||||
const [min, max] = domain!;
|
||||
expect(min).toBe(0);
|
||||
expect(max).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the symmetric flat-series padding for negative flat series", () => {
|
||||
const domain = computePaddedYDomain([-10, -10]);
|
||||
expect(domain).toBeDefined();
|
||||
expect(domain![0]).toBeLessThan(-10);
|
||||
});
|
||||
});
|
||||
@@ -265,7 +265,11 @@ export async function generateBloodPressureStatusForUser(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared Y-axis domain computation for every chart surface (dashboard
|
||||
* charts, insights sub-page charts, mini charts). One source of truth so
|
||||
* the padding rules cannot drift per chart.
|
||||
*
|
||||
* Padding rules:
|
||||
* - Flat series (min === max): pad symmetrically by 5% of the magnitude
|
||||
* (at least 1 unit), with a little extra headroom on top.
|
||||
* - Otherwise: pad the bottom by 8% of the span (min 0.5) and the top by
|
||||
* 16% of the span (min 1).
|
||||
* - Zero clamp: when the series contains no negative values the lower
|
||||
* bound never dips below 0 — padding only extends upward or down to 0.
|
||||
* Counts, steps, weights etc. must not render a negative axis. Series
|
||||
* with genuine negative values (deltas, differences) keep the full
|
||||
* downward padding.
|
||||
*/
|
||||
export function computePaddedYDomain(
|
||||
values: readonly number[],
|
||||
): [number, number] | undefined {
|
||||
const finite = values.filter(
|
||||
(value): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value),
|
||||
);
|
||||
if (!finite.length) return undefined;
|
||||
|
||||
const min = Math.min(...finite);
|
||||
const max = Math.max(...finite);
|
||||
// Non-negative series never get a negative lower bound.
|
||||
const clampLower = (lower: number) => (min >= 0 ? Math.max(0, lower) : lower);
|
||||
|
||||
if (min === max) {
|
||||
const delta = Math.max(Math.abs(min) * 0.05, 1);
|
||||
return [clampLower(min - delta), max + delta * 1.35];
|
||||
}
|
||||
|
||||
const span = max - min;
|
||||
const paddingBottom = Math.max(span * 0.08, 0.5);
|
||||
const paddingTop = Math.max(span * 0.16, 1);
|
||||
return [clampLower(min - paddingBottom), max + paddingTop];
|
||||
}
|
||||
@@ -914,7 +914,11 @@ export async function extractFeatures(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (medications.length > 0) {
|
||||
|
||||
@@ -156,7 +156,11 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeNextDueAt } from "../next-due";
|
||||
import { computeDisplayDue, computeNextDueAt } from "../next-due";
|
||||
import type {
|
||||
WorkerMedicationRow,
|
||||
WorkerScheduleRow,
|
||||
@@ -246,3 +246,93 @@ describe("computeNextDueAt — stale degenerate window never wins over timesOfDa
|
||||
expect(next!.toISOString()).toBe("2026-06-10T19:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.4 — `computeDisplayDue`: an unresolved slot whose anchor has
|
||||
* passed must stay on the card as an OPEN overdue slot while `now` is
|
||||
* still inside its catch-up band (`anchor < now ≤ overdueEnd`); only a
|
||||
* closed or resolved band advances to the future next-due.
|
||||
*/
|
||||
function makeDailySchedule(
|
||||
overrides: Partial<WorkerScheduleRow> = {},
|
||||
): WorkerScheduleRow {
|
||||
return {
|
||||
id: "sched-daily-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeDisplayDue — open overdue slot", () => {
|
||||
// Berlin is UTC+2 in June: the 21:00 local slot on 2026-06-10 is
|
||||
// 19:00Z; its default band is 20:00–22:00 local with a 3 h late tail,
|
||||
// so the catch-up window stays open until 01:00 local (23:00Z).
|
||||
const medication = makeMedication();
|
||||
const schedules = [makeDailySchedule()];
|
||||
|
||||
it("surfaces the unresolved 21:00 slot at 22:30 (inside the catch-up tail) as overdue", () => {
|
||||
const now = d("2026-06-10T20:30:00Z"); // 22:30 Berlin
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules,
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(true);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-10T19:00:00.000Z"); // 21:00 Berlin
|
||||
});
|
||||
|
||||
it("advances to the next future slot (09:00) once the band closed at 02:00", () => {
|
||||
const now = d("2026-06-11T00:00:00Z"); // 02:00 Berlin — past the 01:00 tail end
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules,
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-11T07:00:00.000Z"); // 09:00 Berlin
|
||||
});
|
||||
|
||||
it("treats a slot with a live taken/skipped row on the anchor as resolved", () => {
|
||||
const now = d("2026-06-10T20:30:00Z");
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules,
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
resolvedSlots: [d("2026-06-10T19:00:00Z")],
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-11T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("never claims an overdue anchor from before the current era floor", () => {
|
||||
const now = d("2026-06-10T20:30:00Z");
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules,
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
eraStart: d("2026-06-10T19:30:00Z"), // schedules replaced after the slot
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* v1.16.3 — schedule-era segmentation (effective dating).
|
||||
*
|
||||
* Pins the era-split engine: a schedule replace archives the old state as a
|
||||
* revision, and every historical surface mints past days against the
|
||||
* schedule that was live THEN. Covers the boundary rule (anchor decides era,
|
||||
* tails uncut), DST across an era boundary, a rolling-cadence era, the
|
||||
* zero-revision pass-through, the write-attribution era pick, and the
|
||||
* material-change gate the write path uses.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
attributeTakenToSlot,
|
||||
type AttributeIntakeMedication,
|
||||
} from "@/lib/medications/scheduling/attribute-intake";
|
||||
import { buildBandsForSchedules } from "@/lib/medications/scheduling/band-minter";
|
||||
import { reconstructDoseHistory } from "@/lib/medications/scheduling/dose-history";
|
||||
import type {
|
||||
CanonicalSchedule,
|
||||
RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
canonicalSchedulesFromRevision,
|
||||
schedulesMateriallyDiffer,
|
||||
segmentRangeIntoEras,
|
||||
toRevisionPayloadEntry,
|
||||
type ScheduleRevisionEntry,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function canonical(partial: Partial<CanonicalSchedule>): CanonicalSchedule {
|
||||
return {
|
||||
id: "live-1",
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: [],
|
||||
daysOfWeek: null,
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(partial: Partial<ScheduleRevisionEntry>): ScheduleRevisionEntry {
|
||||
return {
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const createdAt = new Date("2026-05-01T08:00:00.000Z");
|
||||
// The replace happened on 31 May at 10:00 UTC (12:00 Berlin).
|
||||
const replaceAt = new Date("2026-05-31T10:00:00.000Z");
|
||||
|
||||
const medication = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const ctx: RecurrenceContext = {
|
||||
medication,
|
||||
timeZone: TZ,
|
||||
lastIntakeAt: null,
|
||||
};
|
||||
|
||||
const oldEraRevision: ScheduleRevisionLike = {
|
||||
id: "rev-1",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
payload: [entry({})],
|
||||
};
|
||||
|
||||
const liveSchedule = canonical({
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
});
|
||||
|
||||
describe("segmentRangeIntoEras", () => {
|
||||
it("returns a single live era when no revisions exist", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [], [liveSchedule]);
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
expect(eras[0].from).toEqual(range.from);
|
||||
expect(eras[0].to).toEqual(range.to);
|
||||
});
|
||||
|
||||
it("splits the range at validUntil with the revision schedules first", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
|
||||
expect(eras).toHaveLength(2);
|
||||
expect(eras[0].live).toBe(false);
|
||||
expect(eras[0].schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
// Anchor-boundary rule: the archived era ends 1 ms short of validUntil.
|
||||
expect(eras[0].to.getTime()).toBe(replaceAt.getTime() - 1);
|
||||
expect(eras[1].live).toBe(true);
|
||||
expect(eras[1].from).toEqual(replaceAt);
|
||||
});
|
||||
|
||||
it("drops a revision era that does not intersect the range", () => {
|
||||
const range = {
|
||||
from: new Date("2026-06-01T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBandsForSchedulesWithEras", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-20T00:00:00.000Z"),
|
||||
to: new Date("2026-06-05T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
function mintedBands() {
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [oldEraRevision],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
return groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
|
||||
}
|
||||
|
||||
it("mints 07:00/19:00 before the boundary and 09:00/21:00 after", () => {
|
||||
const bands = mintedBands();
|
||||
const before = bands.filter((b) => b.at.getTime() < replaceAt.getTime());
|
||||
const after = bands.filter((b) => b.at.getTime() >= replaceAt.getTime());
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(new Set(before.map((b) => b.timeOfDay))).toEqual(
|
||||
new Set(["07:00", "19:00"]),
|
||||
);
|
||||
expect(new Set(after.map((b) => b.timeOfDay))).toEqual(
|
||||
new Set(["09:00", "21:00"]),
|
||||
);
|
||||
// The new times must not exist before the boundary at all.
|
||||
expect(
|
||||
before.some((b) => b.timeOfDay === "09:00" || b.timeOfDay === "21:00"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is a pass-through with zero revisions", () => {
|
||||
const direct = buildBandsForSchedules({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
const viaEras = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
expect(viaEras).toEqual(direct);
|
||||
});
|
||||
|
||||
it("binds an old-era take on-time to the old 07:00 slot and keeps history compliance positive", () => {
|
||||
const bands = mintedBands();
|
||||
// A take at 07:05 Berlin (05:05 UTC) on 25 May — inside the old era.
|
||||
const takenAt = new Date("2026-05-25T05:05:00.000Z");
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[{ scheduledFor: takenAt, takenAt, skipped: false }],
|
||||
range.to,
|
||||
);
|
||||
const takenRows = rows.filter((r) => r.status === "taken_on_time");
|
||||
expect(takenRows).toHaveLength(1);
|
||||
expect(takenRows[0].timeOfDay).toBe("07:00");
|
||||
const taken = rows.filter(
|
||||
(r) => r.status === "taken_on_time" || r.status === "taken_late",
|
||||
).length;
|
||||
expect(taken).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps local slot times correct across a DST transition inside an era", () => {
|
||||
// Berlin springs forward on 2026-03-29. Old era covers the transition.
|
||||
const dstCreated = new Date("2026-03-20T08:00:00.000Z");
|
||||
const dstReplace = new Date("2026-04-02T10:00:00.000Z");
|
||||
const dstMed = { ...medication, createdAt: dstCreated };
|
||||
const dstCtx: RecurrenceContext = { ...ctx, medication: dstMed };
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication: dstMed,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [
|
||||
{
|
||||
id: "rev-dst",
|
||||
validFrom: dstCreated,
|
||||
validUntil: dstReplace,
|
||||
payload: [entry({ timesOfDay: ["07:00"], windowStart: "07:00", windowEnd: "07:00" })],
|
||||
},
|
||||
],
|
||||
ctx: dstCtx,
|
||||
userTz: TZ,
|
||||
range: {
|
||||
from: new Date("2026-03-25T00:00:00.000Z"),
|
||||
to: new Date("2026-04-05T00:00:00.000Z"),
|
||||
},
|
||||
now: new Date("2026-04-05T00:00:00.000Z"),
|
||||
});
|
||||
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
|
||||
// 07:00 Berlin is 06:00 UTC before the transition, 05:00 UTC after.
|
||||
const mar28 = bands.find((b) => b.at.toISOString().startsWith("2026-03-28"));
|
||||
const mar30 = bands.find((b) => b.at.toISOString().startsWith("2026-03-30"));
|
||||
expect(mar28?.at.toISOString()).toBe("2026-03-28T06:00:00.000Z");
|
||||
expect(mar30?.at.toISOString()).toBe("2026-03-30T05:00:00.000Z");
|
||||
// After the replace the live 09:00/21:00 schedule mints (09:00 = 07:00Z).
|
||||
const apr03 = bands.filter((b) => b.at.toISOString().startsWith("2026-04-03"));
|
||||
expect(apr03.map((b) => b.timeOfDay).sort()).toEqual(["09:00", "21:00"]);
|
||||
});
|
||||
|
||||
it("mints a rolling era from the archived rolling schedule", () => {
|
||||
// Old era: weekly rolling injection. Live: daily oral.
|
||||
const rollingRevision: ScheduleRevisionLike = {
|
||||
id: "rev-roll",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
payload: [
|
||||
entry({
|
||||
timesOfDay: ["08:00"],
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const intakeInstants = [
|
||||
new Date("2026-05-05T06:00:00.000Z"),
|
||||
new Date("2026-05-12T06:10:00.000Z"),
|
||||
new Date("2026-05-19T05:55:00.000Z"),
|
||||
];
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [rollingRevision],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range: {
|
||||
from: new Date("2026-05-01T00:00:00.000Z"),
|
||||
to: new Date("2026-06-05T00:00:00.000Z"),
|
||||
},
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
intakeInstants,
|
||||
});
|
||||
const rollingGroup = groups.find((g) => g.scheduleId.startsWith("rev:rev-roll"));
|
||||
expect(rollingGroup?.family).toBe("weekly");
|
||||
// Each logged intake anchors one band inside the old era.
|
||||
expect(rollingGroup?.bands.length).toBeGreaterThanOrEqual(3);
|
||||
expect(
|
||||
rollingGroup?.bands.every((b) => b.at.getTime() < replaceAt.getTime()),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write-attribution era pick", () => {
|
||||
it("attributes an old takenAt against the era live at that instant", () => {
|
||||
const med: AttributeIntakeMedication = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [
|
||||
{
|
||||
id: "live-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [oldEraRevision],
|
||||
};
|
||||
// Editing a take back to 25 May 07:10 Berlin (05:10 UTC) — old era.
|
||||
const takenAt = new Date("2026-05-25T05:10:00.000Z");
|
||||
const result = attributeTakenToSlot({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
});
|
||||
expect(result.slotInstant?.toISOString()).toBe("2026-05-25T05:00:00.000Z");
|
||||
expect(result.status).toBe("on_time");
|
||||
// The same instant against the live-only schedules would NOT be a
|
||||
// 07:00-slot match — the revision is what fixes the attribution.
|
||||
const liveOnly = attributeTakenToSlot({
|
||||
medication: { ...med, scheduleRevisions: [] },
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
});
|
||||
expect(liveOnly.slotInstant?.toISOString()).not.toBe(
|
||||
"2026-05-25T05:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalSchedulesFromRevision", () => {
|
||||
it("rebuilds canonical schedules with synthetic ids and tolerates malformed payloads", () => {
|
||||
const schedules = canonicalSchedulesFromRevision(oldEraRevision);
|
||||
expect(schedules).toHaveLength(1);
|
||||
expect(schedules[0].id).toBe("rev:rev-1:0");
|
||||
expect(schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
expect(
|
||||
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: "garbage" }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: [null] }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulesMateriallyDiffer", () => {
|
||||
it("ignores label/dose changes and row order", () => {
|
||||
const a = [entry({ label: "Morgens" }), entry({ timesOfDay: ["12:00"] })];
|
||||
const b = [
|
||||
entry({ timesOfDay: ["12:00"], dose: "2" }),
|
||||
entry({ label: "Abends" }),
|
||||
];
|
||||
expect(schedulesMateriallyDiffer(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores timesOfDay ordering", () => {
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({ timesOfDay: ["19:00", "07:00"] })],
|
||||
[entry({ timesOfDay: ["07:00", "19:00"] })],
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a times change, a count change, and a doseWindows change", () => {
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({})],
|
||||
[entry({ timesOfDay: ["09:00", "21:00"] })],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(schedulesMateriallyDiffer([entry({})], [entry({}), entry({})])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({})],
|
||||
[
|
||||
entry({
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "06:30", end: "08:30" }],
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through toRevisionPayloadEntry", () => {
|
||||
const row = {
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED" as const,
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: "Alt",
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
};
|
||||
expect(
|
||||
schedulesMateriallyDiffer([toRevisionPayloadEntry(row)], [entry({})]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,14 @@ import {
|
||||
type SlotBand,
|
||||
} from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForMedication,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
@@ -46,6 +49,13 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** The medication projection the attributor reads (matches the write `select`). */
|
||||
export interface AttributeIntakeMedication extends WorkerMedicationRow {
|
||||
schedules: WorkerScheduleRow[];
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras, `validFrom` ascending. When present
|
||||
* the day bands around a historical `takenAt` mint from the era that was
|
||||
* live THEN, so an edit of an old take attributes against the old times.
|
||||
* Optional: callers without revisions keep the live-only behaviour.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
export interface AttributeTakenInput {
|
||||
@@ -145,25 +155,34 @@ export function buildMedicationDayBands(input: {
|
||||
to: new Date(input.around.getTime() + dayScaleReachDays * ONE_DAY_MS),
|
||||
};
|
||||
|
||||
const bands: SlotBand[] = [];
|
||||
let hasExpectedSlots = false;
|
||||
for (const schedule of canonicalSchedules) {
|
||||
const common = {
|
||||
// v1.16.3 — bands mint era-aware: a historical `around` inside an archived
|
||||
// era gets THAT era's schedules. With no revisions the wrapper passes
|
||||
// straight through to the live-only minter.
|
||||
const mintEras = (range: { from: Date; to: Date }) =>
|
||||
buildBandsForSchedulesWithEras({
|
||||
medication: bandMinterMedication,
|
||||
schedule,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: input.medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz: input.userTz,
|
||||
range,
|
||||
now: input.now ?? input.around,
|
||||
windowConfig: input.windowConfig,
|
||||
intakeInstants: input.intakeInstants,
|
||||
};
|
||||
let result = buildBandsForMedication({ ...common, range: minuteScaleRange });
|
||||
if (result.family === "weekly") {
|
||||
result = buildBandsForMedication({ ...common, range: dayScaleRange });
|
||||
}
|
||||
if (result.hasExpectedSlots) {
|
||||
});
|
||||
let groups = mintEras(minuteScaleRange);
|
||||
// Day-scale families re-mint over the wide range (the same per-schedule
|
||||
// narrow-then-wide pass as before, applied per group).
|
||||
if (groups.some((g) => g.family === "weekly")) {
|
||||
const wide = mintEras(dayScaleRange).filter((g) => g.family === "weekly");
|
||||
groups = [...groups.filter((g) => g.family !== "weekly"), ...wide];
|
||||
}
|
||||
const bands: SlotBand[] = [];
|
||||
let hasExpectedSlots = false;
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) {
|
||||
hasExpectedSlots = true;
|
||||
bands.push(...result.bands);
|
||||
bands.push(...g.bands);
|
||||
}
|
||||
}
|
||||
return { bands, hasExpectedSlots };
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
buildBandsForSchedulesWithEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type HistoryIntake,
|
||||
@@ -125,6 +126,12 @@ export interface CadenceEngineContext {
|
||||
lastIntakeAt: Date | null;
|
||||
/** User IANA timezone. Required for the engine to apply HH:mm slots. */
|
||||
timeZone: string;
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present
|
||||
* the ledger-band consumers mint past days against the schedule that was
|
||||
* live THEN. Optional: callers without revisions keep live-only minting.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
|
||||
@@ -892,9 +899,10 @@ function missedFromLedger(
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: engineCtx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz,
|
||||
range: { from, to: asOf },
|
||||
|
||||
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
|
||||
import type { SlotBand } from "./attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "./band-minter";
|
||||
import { type BandMinterMedication } from "./band-minter";
|
||||
import { buildBandsForSchedulesWithEras } from "./schedule-eras";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
startOfLocalDay,
|
||||
@@ -234,9 +232,10 @@ function ledgerChipCounts(
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: engineCtx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz,
|
||||
range: { from, to: asOf },
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type WorkerScheduleRow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence";
|
||||
import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
|
||||
/**
|
||||
* v1.15.10 — radius an intake may sit from a slot's canonical instant and
|
||||
@@ -27,6 +29,22 @@ import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence";
|
||||
*/
|
||||
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.16.4 — how far back the open-overdue search mints bands. The widest
|
||||
* possible band reach is a weekly slot's on-time half-width plus its
|
||||
* overdue tail (1 + 4 days); one spare day absorbs DST / timezone skew.
|
||||
* Exported so the list route can widen its resolved-slot read to the
|
||||
* same horizon.
|
||||
*/
|
||||
export const OVERDUE_LOOKBACK_MS =
|
||||
(DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays +
|
||||
DOSE_WINDOW_DEFAULTS.weeklyOverdueDays +
|
||||
1) *
|
||||
24 *
|
||||
60 *
|
||||
60 *
|
||||
1000;
|
||||
|
||||
export function computeNextDueAt(input: {
|
||||
medication: WorkerMedicationRow;
|
||||
schedules: WorkerScheduleRow[];
|
||||
@@ -76,3 +94,99 @@ export function computeNextDueAt(input: {
|
||||
}
|
||||
return earliest;
|
||||
}
|
||||
|
||||
/** The instant a medication card should surface, plus its overdue state. */
|
||||
export interface DisplayDue {
|
||||
at: Date;
|
||||
/**
|
||||
* True when `at` is an OPEN overdue slot: its anchor has passed but `now`
|
||||
* is still inside the slot's catch-up band (`anchor < now ≤ overdueEnd`)
|
||||
* and the user has not acted on it. The card renders this slot as
|
||||
* "overdue — still takeable" instead of jumping to the next future slot.
|
||||
*/
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
export interface ComputeDisplayDueInput {
|
||||
medication: WorkerMedicationRow;
|
||||
schedules: WorkerScheduleRow[];
|
||||
now: Date;
|
||||
userTz: string;
|
||||
lastIntakeAt: Date | null;
|
||||
resolvedSlots?: Date[];
|
||||
/**
|
||||
* Floor of the CURRENT schedule era (the newest revision's `validUntil`),
|
||||
* when the medication has archived revisions. The overdue search mints
|
||||
* bands from the LIVE schedule rows only, so it must not reach back past
|
||||
* the era boundary — a pre-edit slot belongs to the old era's cadence and
|
||||
* must not be re-minted at the new times.
|
||||
*/
|
||||
eraStart?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.4 — the display-due resolution for the medication list cards.
|
||||
*
|
||||
* `computeNextDueAt` walks strictly forward from `now`, so the moment a
|
||||
* slot's anchor passed the card jumped to the NEXT slot — even while the
|
||||
* dose was still takeable inside its catch-up band. This wrapper first
|
||||
* searches the current era for an open overdue slot (band model:
|
||||
* `anchor < now ≤ overdueEnd`, no taken / skipped / auto-missed row on the
|
||||
* anchor) and surfaces it with `overdue: true`; only when every passed
|
||||
* band is closed or resolved does it fall through to the future next-due.
|
||||
*/
|
||||
export function computeDisplayDue(
|
||||
input: ComputeDisplayDueInput,
|
||||
): DisplayDue | null {
|
||||
const open = findOpenOverdueSlot(input);
|
||||
if (open) return { at: open, overdue: true };
|
||||
const next = computeNextDueAt(input);
|
||||
return next ? { at: next, overdue: false } : null;
|
||||
}
|
||||
|
||||
function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null {
|
||||
const { medication, schedules, now, userTz, lastIntakeAt } = input;
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
const resolved = input.resolvedSlots ?? [];
|
||||
const isResolved = (slotAt: Date): boolean => {
|
||||
const slot = slotAt.getTime();
|
||||
for (const r of resolved) {
|
||||
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
|
||||
if (input.eraStart && input.eraStart.getTime() > floor.getTime()) {
|
||||
floor = input.eraStart;
|
||||
}
|
||||
if (floor.getTime() >= now.getTime()) return null;
|
||||
|
||||
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
|
||||
let latest: Date | null = null;
|
||||
for (const schedule of schedules) {
|
||||
const { bands } = buildBandsForMedication({
|
||||
medication,
|
||||
schedule: buildCanonicalSchedule(schedule),
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from: floor, to: now },
|
||||
now,
|
||||
intakeInstants: lastIntakeAt ? [lastIntakeAt] : [],
|
||||
});
|
||||
for (const band of bands) {
|
||||
const anchor = band.at.getTime();
|
||||
// Open overdue: the anchor has passed, now is still inside the
|
||||
// catch-up band, and no live intake row resolves the slot. An anchor
|
||||
// before the era floor is rejected explicitly — the minter works in
|
||||
// local-day granularity, so the range floor alone is not a guarantee.
|
||||
if (anchor < floor.getTime()) continue;
|
||||
if (anchor >= now.getTime()) continue;
|
||||
if (now.getTime() > band.overdueEnd.getTime()) continue;
|
||||
if (isResolved(band.at)) continue;
|
||||
if (latest === null || anchor > latest.getTime()) latest = band.at;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* v1.16.3 — schedule-era segmentation (effective dating).
|
||||
*
|
||||
* A wholesale schedule replace used to rewrite history: bands for PAST days
|
||||
* were minted from the CURRENT `medication_schedules` rows, so after a
|
||||
* times edit the whole old era read "missed at the new times". The write
|
||||
* path now archives each superseded schedule state as one
|
||||
* `MedicationScheduleRevision` row covering `[validFrom, validUntil)`; this
|
||||
* module turns those revisions into minting eras so every historical
|
||||
* surface (dose-history ledger, compliance tally, expected-slot counts,
|
||||
* write/edit attribution) reads a past day against the schedule that was
|
||||
* live THEN.
|
||||
*
|
||||
* Era rules:
|
||||
* - revisions are chained: `validFrom` = previous revision's `validUntil`
|
||||
* (or `medication.createdAt` for the first), `validUntil` = the replace
|
||||
* instant. The LIVE rows cover `[newest validUntil, ∞)`.
|
||||
* - boundary: a slot belongs to the era its ANCHOR lies in. Eras mint
|
||||
* with an inclusive sub-range capped 1 ms short of `validUntil`, so an
|
||||
* anchor exactly at the boundary mints in the NEXT era only. On-time /
|
||||
* late tails are NOT clipped — a band minted near the end of an era
|
||||
* keeps its full reach past the boundary.
|
||||
* - `startsOn` stays the global floor for all eras (the engine applies
|
||||
* it per occurrence); a times-only edit never moves it.
|
||||
*
|
||||
* Pure / synchronous like the band minter: revisions are pre-fetched by the
|
||||
* caller and threaded in — no DB access here.
|
||||
*/
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
type ScheduleBandGroup,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
SCHEDULE_TYPES,
|
||||
type CanonicalSchedule,
|
||||
type Occurrence,
|
||||
type RecurrenceContext,
|
||||
type ScheduleType,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
|
||||
/** The revision projection the era splitter reads (a Prisma row drops in). */
|
||||
export interface ScheduleRevisionLike {
|
||||
id: string;
|
||||
/** Inclusive start of the archived era. */
|
||||
validFrom: Date;
|
||||
/** Exclusive end of the archived era (the replace instant). */
|
||||
validUntil: Date;
|
||||
/** JSON array of {@link ScheduleRevisionEntry} snapshots. */
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One archived schedule row inside a revision's `payload` array. The write
|
||||
* path snapshots every cadence-relevant column so the era splitter can
|
||||
* rebuild a full `CanonicalSchedule`; `label` / `dose` ride along for
|
||||
* display-only consumers but never participate in the material-change
|
||||
* compare.
|
||||
*/
|
||||
export interface ScheduleRevisionEntry {
|
||||
timesOfDay: string[];
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
scheduleType: ScheduleType;
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
doseWindows: unknown;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
reminderGraceMinutes: number | null;
|
||||
}
|
||||
|
||||
/** One minting era: an inclusive `[from, to]` sub-range + its schedules. */
|
||||
export interface ScheduleEra {
|
||||
from: Date;
|
||||
to: Date;
|
||||
schedules: CanonicalSchedule[];
|
||||
/** True for the era backed by the live `medication_schedules` rows. */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the `CanonicalSchedule` list a revision archived. Synthetic ids
|
||||
* (`rev:<revisionId>:<idx>`) keep the engine occurrence/group plumbing
|
||||
* stable without colliding with live schedule ids. Defensive parsing: a
|
||||
* malformed payload entry degrades to an empty/cadence-less schedule the
|
||||
* band minter already treats as "no slot machinery" rather than throwing
|
||||
* on a read path.
|
||||
*/
|
||||
export function canonicalSchedulesFromRevision(
|
||||
revision: ScheduleRevisionLike,
|
||||
options?: { oneShot?: boolean },
|
||||
): CanonicalSchedule[] {
|
||||
if (!Array.isArray(revision.payload)) return [];
|
||||
return revision.payload.map((raw, idx) => {
|
||||
const e = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
|
||||
const base: CanonicalSchedule = {
|
||||
id: `rev:${revision.id}:${idx}`,
|
||||
rrule: typeof e.rrule === "string" ? e.rrule : null,
|
||||
rollingIntervalDays:
|
||||
typeof e.rollingIntervalDays === "number" ? e.rollingIntervalDays : null,
|
||||
timesOfDay: Array.isArray(e.timesOfDay)
|
||||
? e.timesOfDay.filter((t): t is string => typeof t === "string")
|
||||
: [],
|
||||
daysOfWeek: typeof e.daysOfWeek === "string" ? e.daysOfWeek : null,
|
||||
windowStart: typeof e.windowStart === "string" ? e.windowStart : "08:00",
|
||||
windowEnd: typeof e.windowEnd === "string" ? e.windowEnd : "08:00",
|
||||
reminderGraceMinutes:
|
||||
typeof e.reminderGraceMinutes === "number"
|
||||
? e.reminderGraceMinutes
|
||||
: null,
|
||||
scheduleType: SCHEDULE_TYPES.includes(e.scheduleType as ScheduleType)
|
||||
? (e.scheduleType as ScheduleType)
|
||||
: "SCHEDULED",
|
||||
cyclicOnWeeks:
|
||||
typeof e.cyclicOnWeeks === "number" ? e.cyclicOnWeeks : null,
|
||||
cyclicOffWeeks:
|
||||
typeof e.cyclicOffWeeks === "number" ? e.cyclicOffWeeks : null,
|
||||
doseWindows: normaliseDoseWindows(e.doseWindows),
|
||||
};
|
||||
// A legacy daily row carrying only `windowStart` surfaces it as the
|
||||
// single time-of-day so the minter mints its daily band — the same
|
||||
// normalisation every band-building consumer applies to live rows.
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
base.rrule === null &&
|
||||
base.rollingIntervalDays === null &&
|
||||
base.scheduleType !== "PRN" &&
|
||||
options?.oneShot !== true
|
||||
) {
|
||||
return { ...base, timesOfDay: [base.windowStart] };
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment an inclusive minting `range` into eras. Each revision interval
|
||||
* that intersects the range contributes one era with ITS archived
|
||||
* schedules; the remainder (from the newest `validUntil` onward) is the
|
||||
* live era. Eras are returned chronologically; an empty intersection is
|
||||
* dropped. With no revisions the result is a single live era covering the
|
||||
* whole range — the zero-revision path is byte-equivalent to not
|
||||
* segmenting at all.
|
||||
*/
|
||||
export function segmentRangeIntoEras(
|
||||
range: { from: Date; to: Date },
|
||||
revisions: ScheduleRevisionLike[],
|
||||
liveSchedules: CanonicalSchedule[],
|
||||
options?: { oneShot?: boolean },
|
||||
): ScheduleEra[] {
|
||||
const eras: ScheduleEra[] = [];
|
||||
const sorted = [...revisions].sort(
|
||||
(a, b) => a.validFrom.getTime() - b.validFrom.getTime(),
|
||||
);
|
||||
for (const revision of sorted) {
|
||||
const from = new Date(
|
||||
Math.max(revision.validFrom.getTime(), range.from.getTime()),
|
||||
);
|
||||
// Inclusive sub-range capped 1 ms short of `validUntil` — the anchor
|
||||
// boundary rule: a slot exactly at the boundary belongs to the era
|
||||
// that begins there, never to the one ending there.
|
||||
const to = new Date(
|
||||
Math.min(revision.validUntil.getTime() - 1, range.to.getTime()),
|
||||
);
|
||||
if (from.getTime() > to.getTime()) continue;
|
||||
eras.push({
|
||||
from,
|
||||
to,
|
||||
schedules: canonicalSchedulesFromRevision(revision, options),
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
const liveFrom =
|
||||
sorted.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
sorted[sorted.length - 1].validUntil.getTime(),
|
||||
range.from.getTime(),
|
||||
),
|
||||
)
|
||||
: range.from;
|
||||
if (liveFrom.getTime() <= range.to.getTime()) {
|
||||
eras.push({ from: liveFrom, to: range.to, schedules: liveSchedules, live: true });
|
||||
}
|
||||
return eras;
|
||||
}
|
||||
|
||||
/**
|
||||
* Era-aware drop-in for `buildBandsForSchedules`: segments the range into
|
||||
* eras and mints each era with the schedules that were live THEN,
|
||||
* concatenating the per-schedule groups. Archived-era groups carry the
|
||||
* synthetic `rev:<id>:<idx>` schedule ids. With no revisions this is a
|
||||
* plain pass-through to `buildBandsForSchedules` — every existing caller
|
||||
* keeps its exact behaviour until it threads revisions in.
|
||||
*/
|
||||
export function buildBandsForSchedulesWithEras(input: {
|
||||
medication: BandMinterMedication;
|
||||
schedules: CanonicalSchedule[];
|
||||
revisions: ScheduleRevisionLike[];
|
||||
ctx: RecurrenceContext;
|
||||
userTz: string;
|
||||
range: { from: Date; to: Date };
|
||||
now: Date;
|
||||
windowConfig?: DoseWindowConfig;
|
||||
intakeInstants?: Date[];
|
||||
}): ScheduleBandGroup[] {
|
||||
if (input.revisions.length === 0) {
|
||||
return buildBandsForSchedules(input);
|
||||
}
|
||||
const eras = segmentRangeIntoEras(input.range, input.revisions, input.schedules, {
|
||||
oneShot: input.medication.oneShot,
|
||||
});
|
||||
const groups: ScheduleBandGroup[] = [];
|
||||
for (const era of eras) {
|
||||
groups.push(
|
||||
...buildBandsForSchedules({
|
||||
medication: input.medication,
|
||||
schedules: era.schedules,
|
||||
ctx: input.ctx,
|
||||
userTz: input.userTz,
|
||||
range: { from: era.from, to: era.to },
|
||||
now: input.now,
|
||||
windowConfig: input.windowConfig,
|
||||
intakeInstants: input.intakeInstants,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Era-aware occurrence expansion for the count-shaped consumers
|
||||
* (`expectedSlotCountForDay` / `expectedSlotsBetween`): expands each era's
|
||||
* schedules over the era's sub-range via the supplied expander and returns
|
||||
* the chronological union. The expander signature matches the per-schedule
|
||||
* loop those callers already run, so the era split slots in without
|
||||
* duplicating their retro-rolling plumbing.
|
||||
*/
|
||||
export function occurrencesAcrossEras(
|
||||
range: { from: Date; to: Date },
|
||||
revisions: ScheduleRevisionLike[],
|
||||
liveSchedules: CanonicalSchedule[],
|
||||
expand: (schedule: CanonicalSchedule, from: Date, to: Date) => Occurrence[],
|
||||
options?: { oneShot?: boolean },
|
||||
): Occurrence[] {
|
||||
const eras = segmentRangeIntoEras(range, revisions, liveSchedules, options);
|
||||
const all: Occurrence[] = [];
|
||||
for (const era of eras) {
|
||||
for (const schedule of era.schedules) {
|
||||
all.push(...expand(schedule, era.from, era.to));
|
||||
}
|
||||
}
|
||||
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Write-path helpers: snapshot + material-change compare
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The schedule-row projection the snapshot reads (a Prisma row drops in). */
|
||||
export interface ScheduleSnapshotRow {
|
||||
timesOfDay: string[];
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
scheduleType: ScheduleType;
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
doseWindows: unknown;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
reminderGraceMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Snapshot one schedule row into a revision payload entry. */
|
||||
export function toRevisionPayloadEntry(
|
||||
row: ScheduleSnapshotRow,
|
||||
): ScheduleRevisionEntry {
|
||||
return {
|
||||
timesOfDay: [...row.timesOfDay],
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows ?? null,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalised cadence fingerprint of one snapshot entry. Sorted
|
||||
* `timesOfDay`, normalised `doseWindows` sorted by `timeOfDay`, and only
|
||||
* the fields that change WHICH slots/bands history mints — `label` and
|
||||
* `dose` are display-only and excluded, so renaming a schedule never
|
||||
* archives a phantom revision.
|
||||
*/
|
||||
function cadenceFingerprint(entry: ScheduleRevisionEntry): string {
|
||||
const windows = (normaliseDoseWindows(entry.doseWindows) ?? [])
|
||||
.map((w) => `${w.timeOfDay}>${w.start}-${w.end}`)
|
||||
.sort();
|
||||
return JSON.stringify({
|
||||
timesOfDay: [...entry.timesOfDay].sort(),
|
||||
windowStart: entry.windowStart,
|
||||
windowEnd: entry.windowEnd,
|
||||
daysOfWeek: entry.daysOfWeek,
|
||||
rrule: entry.rrule,
|
||||
rollingIntervalDays: entry.rollingIntervalDays,
|
||||
scheduleType: entry.scheduleType,
|
||||
cyclicOnWeeks: entry.cyclicOnWeeks,
|
||||
cyclicOffWeeks: entry.cyclicOffWeeks,
|
||||
doseWindows: windows,
|
||||
reminderGraceMinutes: entry.reminderGraceMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the two schedule sets differ in any cadence-relevant field —
|
||||
* the gate for archiving a revision on a wholesale replace. Order- and
|
||||
* label-insensitive: a no-op edit (the editor echoing the same rows back,
|
||||
* possibly reordered or relabelled) must NOT mint a revision.
|
||||
*/
|
||||
export function schedulesMateriallyDiffer(
|
||||
before: ScheduleRevisionEntry[],
|
||||
after: ScheduleRevisionEntry[],
|
||||
): boolean {
|
||||
if (before.length !== after.length) return true;
|
||||
const a = before.map(cadenceFingerprint).sort();
|
||||
const b = after.map(cadenceFingerprint).sort();
|
||||
return a.some((fp, i) => fp !== b[i]);
|
||||
}
|
||||
@@ -333,6 +333,12 @@ const MEDICATION_SELECT = {
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: { select: SCHEDULE_SELECT },
|
||||
// v1.16.3 — archived schedule eras: a write/edit at a historical instant
|
||||
// attributes against the era that was live at that takenAt.
|
||||
scheduleRevisions: {
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { id: true, validFrom: true, validUntil: true, payload: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ResolveSlotForWriteInput {
|
||||
|
||||
@@ -1093,7 +1093,12 @@ const medicationResource = z
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s staleness is accepted.",
|
||||
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.",
|
||||
),
|
||||
nextDueOverdue: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.",
|
||||
),
|
||||
startsOn: z.iso
|
||||
.datetime({ offset: true })
|
||||
|
||||
Reference in new issue
Block a user