diff --git a/CHANGELOG.md b/CHANGELOG.md index e6164b733..aabea1cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5d212b1aa..18bb1967f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index 609ae58b3..e71dc5e05 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -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). diff --git a/messages/de.json b/messages/de.json index 1adc9678f..941a9dfdb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 2b5dbd14d..f8c8b45d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 672dc8131..97de64056 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 968dd8b41..7293a6fb9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 3fc1e8e5f..6946a612c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 9f946a149..a1e74fc03 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index ead152bd9..8f1f3da18 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0144_v1163_schedule_revisions/migration.sql b/prisma/migrations/0144_v1163_schedule_revisions/migration.sql new file mode 100644 index 000000000..559bb5a01 --- /dev/null +++ b/prisma/migrations/0144_v1163_schedule_revisions/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cbe6696b..2f28806ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 593a5d353..44c4d4f31 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -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 { windowsReconciled: 0, staleAnchorRows: 0, staleAnchorTombstoned: 0, + eraCandidates: 0, + eraRevisionsCreated: 0, + startsOnCandidates: 0, + startsOnAdjusted: 0, rollupsRecomputed: 0, }; @@ -807,13 +835,245 @@ async function main(): Promise { } // ───────────────────────────────────────────────────────────────── - // 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(); + 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>(); + 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(); + 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(); + 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 { 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}`); } diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 18ed1f860..3e91d9748 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -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" } }, + }, }), ]); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 44a509689..8aef95fdb 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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), diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 56e58c119..eb0a8a307 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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" }, }); diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index b7daa7577..8ca809f0a 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 91911c24b..fb06a98a3 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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( diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 404fe8590..2265171a5 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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) { diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 11c47b7a2..0c583aac6 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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 }, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 0a3920549..4cc161078 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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), }, }), }; diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 15548a6dc..ef19121db 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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({ diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index e9ec1fdf4..1a784e619 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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(); @@ -92,6 +111,11 @@ async function buildMedicationsList( else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); } + const eraStartByMedId = new Map(); + 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, }; }); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ad4f499ba..19813c9a9 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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, diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 7ee72dbc9..465680541 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -157,18 +157,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {t("nav.notifications")} - {/* "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. */} - - - - {t("nav.about")} - - + {/* The about section lives at the end of the settings shell nav; + the avatar menu stays focused on account-level actions. */} {themeIcon} diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index d6b5cbd96..0f14c672c 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -213,3 +213,76 @@ describe(" — next-due reads from server nextDueAt", () => { expect(html).toContain("Tomorrow,"); }); }); + +describe(" — 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) { + 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( + {}} + 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( + {}} + onOpenHistory={() => {}} + />, + client, + ); + + expect(html).toContain("Next intake:"); + expect(html).toContain("Tomorrow"); + expect(html).not.toContain("Overdue ·"); + expect(html).not.toContain("can still be taken"); + }); +}); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index c1adab774..aabb41e1a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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 && ( - - {" "} - — {schedule.dose} - - )} - + overdueLabel ? ( + + {t("medications.nextIntakeOverdue", { time: overdueLabel })} + + ) : ( + <> + {nextInjectionLabel()} + {schedule?.dose && ( + + {" "} + — {schedule.dose} + + )} + + ) ) : null; // v1.15.9 — the last-injection line drops the injection-site display; it diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index d74f5ced3..cd9f8f339 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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 ( + + {t("medications.nextIntakeOverdue", { + time: formatTime(new Date(nextAt).toISOString()), + })} + + ); + } + // Format day label relative to today let dayLabel = ""; if (nextAt) { diff --git a/src/components/onboarding/__tests__/tour-position.test.ts b/src/components/onboarding/__tests__/tour-position.test.ts new file mode 100644 index 000000000..d9c05559d --- /dev/null +++ b/src/components/onboarding/__tests__/tour-position.test.ts @@ -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); + } + } + }); +}); diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index 282fb529c..ae350d47e 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -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( + `[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 `