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 `