From bd78cd7f7ad348f063d28f7d97088cb49d4a0924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 22:21:44 +0200 Subject: [PATCH] feat(medications): schedules remember their history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a schedule's times no longer rewrites the past. Each material change archives the outgoing cadence as a dated revision, and every band consumer — history, compliance, write attribution, the auto-miss cutoff — mints past periods from the schedule that was valid at the time, so a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistent old anchors behind an explicit flag. The welcome tour scrolls its target into view, flips above it when space below runs out and falls back to a centred sheet on any viewport, so no step can strand its buttons below the fold. The avatar menu drops the about entry — that lives in settings. --- docs/ops/intake-repair.md | 29 ++ .../migration.sql | 37 ++ prisma/schema.prisma | 47 ++ scripts/repair-intake-anomalies.ts | 268 ++++++++++- src/app/api/insights/cards/route.ts | 6 +- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/insights/targets/route.ts | 6 +- .../medications/[id]/__tests__/route.test.ts | 133 ++++++ src/app/api/medications/[id]/cadence/route.ts | 7 +- .../api/medications/[id]/compliance/route.ts | 6 +- .../medications/[id]/dose-history/route.ts | 12 +- src/app/api/medications/[id]/route.ts | 221 ++++++--- src/app/api/medications/intake/route.ts | 6 +- src/components/layout/sidebar-nav.tsx | 14 +- .../__tests__/tour-position.test.ts | 100 +++++ src/components/onboarding/tour.tsx | 198 ++++---- src/lib/analytics/compliance.ts | 71 +-- src/lib/insights/blood-pressure-status.ts | 6 +- src/lib/insights/features.ts | 6 +- .../insights/medication-compliance-status.ts | 6 +- .../__tests__/schedule-eras.test.ts | 424 ++++++++++++++++++ .../scheduling/attribute-intake.ts | 45 +- src/lib/medications/scheduling/cadence.ts | 16 +- src/lib/medications/scheduling/compliance.ts | 9 +- .../medications/scheduling/schedule-eras.ts | 344 ++++++++++++++ src/lib/medications/scheduling/slot-upsert.ts | 6 + 26 files changed, 1806 insertions(+), 223 deletions(-) create mode 100644 prisma/migrations/0144_v1163_schedule_revisions/migration.sql create mode 100644 src/components/onboarding/__tests__/tour-position.test.ts create mode 100644 src/lib/medications/scheduling/__tests__/schedule-eras.test.ts create mode 100644 src/lib/medications/scheduling/schedule-eras.ts 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/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..f191ed264 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -17,6 +17,11 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { + schedulesMateriallyDiffer, + toRevisionPayloadEntry, +} from "@/lib/medications/scheduling/schedule-eras"; +import type { Prisma } from "@/generated/prisma/client"; import { computeNextDueAt } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, @@ -174,7 +179,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 +285,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 +514,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/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/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 `