Merge pull request #309 from MBombeck/release/v1.16.3

Release v1.16.3 — schedules remember their history
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-10 22:48:49 +02:00
commit 963831d4ec
45 files changed
+2347 -268

No files matched your search

+8
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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).
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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;
+47
View File
@@ -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
+266 -2
View File
@@ -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}`);
}
+5 -1
View File
@@ -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" } },
},
}),
]);
+5 -1
View File
@@ -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),
+5 -1
View File
@@ -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 },
+171 -73
View File
@@ -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),
},
}),
};
+5 -1
View File
@@ -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({
+36 -5
View File
@@ -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,
};
});
}
+2 -14
View File
@@ -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,
+2 -12
View File
@@ -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);
}
}
});
});
+118 -80
View File
@@ -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`,
}
}
>
+45 -26
View File
@@ -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);
});
});
+5 -1
View File
@@ -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(
+40
View File
@@ -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];
}
+5 -1
View File
@@ -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 };
+12 -4
View File
@@ -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 },
+4 -5
View File
@@ -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 },
+114
View File
@@ -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 {
+6 -1
View File
@@ -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 })