From eb2c7ced73b43d1d5c8ee823da3b52697e444ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:00:02 +0200 Subject: [PATCH 01/11] fix(medications): read pending doses as upcoming until their window closes A server-minted pending row (taken_at null, not skipped, not auto-missed) was scored missed the moment its slot time passed, so the history tab and the compliance rate punished doses that were still takeable under the band model. Pending rows now read upcoming until the band's overdue end; auto-missed keeps its terminal verdict. Pending rows outside the minted band window are dropped instead of surfacing as phantom ad-hoc lines. --- .../analytics/__tests__/compliance.test.ts | 69 ++++++++++++++ .../scheduling/__tests__/dose-history.test.ts | 90 +++++++++++++++++++ .../medications/scheduling/dose-history.ts | 29 ++++-- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index a802f37b9..11b25c5d1 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -2582,6 +2582,75 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { expect(tally.rate).toBe(100); }); + // v1.15.19 — intraday pending rows. The today-projector mints pending + // rows (takenAt = null, not skipped, not auto-missed) for every slot of + // the day up front; from slot time onward the old ledger read them as + // missed, so the rate dipped intraday although the dose was still inside + // its overdue window. + it("a pending row inside its overdue window reads upcoming — the intraday rate holds", () => { + // 07:00 taken; the 19:00 pending row was minted up front. At 20:00 the + // evening dose is still takeable (the late tail runs to 23:00), so it + // stays out of the denominator and the rate holds at 100, not 50. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + at(23, 59), + at(20, 0), + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(1); + expect(tally.rate).toBe(100); + }); + + it("a pending row past its overdueEnd IS a miss", () => { + // Same shape, but read at 23:59 — the 19:00 dose's tail has closed. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(1); + expect(tally.rate).toBe(50); + }); + + it("a pending row whose slot lies outside `to = now` never becomes a phantom ad-hoc", () => { + // The history view asks with `to = now`. The 19:00 band is not minted + // at 14:00, but the projector already wrote the evening pending row — + // it must be dropped, not surfaced as an ad-hoc row with takenAt=null. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const nowAfternoon = at(14, 0); + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowAfternoon, + nowAfternoon, + ); + expect(tally.adHoc).toBe(0); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => { const events = [ { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 38f709960..0b52efba5 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -141,6 +141,96 @@ describe("reconstructDoseHistory", () => { expect(adhoc[0].intake?.id).toBe("b"); }); + // v1.15.19 — time-aware pending-row semantics. The today-projector and the + // reminder worker mint pending rows (takenAt = null, neither skipped nor + // auto-missed) for every slot of the day up front, so a pending row's + // status must be derived from the clock exactly like an unfilled slot's: + // missed only past the slot's miss cutoff, upcoming until then. + describe("pending (server-minted) rows", () => { + it("reads upcoming while now is inside the slot's overdue window", () => { + // 07:00 slot: on-time ends 08:00, late tail ends 11:00. At 08:30 the + // dose is still takeable — the pending row must not read missed. + const rows = reconstructDoseHistory( + bands, + [intake({ scheduledFor: at(7, 0) })], + at(8, 30), + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("upcoming"); + // The row stays bound to its slot (the intake annotates it). + expect(morning?.intake?.id).toBe("i"); + }); + + it("reads missed once now is past the slot's overdueEnd", () => { + // 07:00 band's miss cutoff is 11:00; at 12:00 the dose is gone. + const rows = reconstructDoseHistory( + bands, + [intake({ scheduledFor: at(7, 0) })], + at(12, 0), + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("weekly slot: a pending row 2 days past the anchor stays upcoming inside the 4-day tail", () => { + const HOUR_MS = 60 * MIN; + const DAY_MS = 24 * HOUR_MS; + const weeklyAt = localHmAsUtc(day, TZ, 7, 0); + const weeklyBands = buildSlotBands([ + { + at: weeklyAt, + timeOfDay: "07:00", + onTimeStart: new Date(weeklyAt.getTime() - 12 * HOUR_MS), + onTimeEnd: new Date(weeklyAt.getTime() + 12 * HOUR_MS), + // Weekly injectable: the clinical 4-day late tail. + lateGraceMs: 4 * DAY_MS, + }, + ]); + const twoDaysLater = new Date(weeklyAt.getTime() + 2 * DAY_MS); + const rows = reconstructDoseHistory( + weeklyBands, + [intake({ scheduledFor: weeklyAt })], + twoDaysLater, + ); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("upcoming"); + }); + + it("drops a pending row matching no band instead of emitting a phantom ad-hoc row", () => { + // `to = now` early-afternoon read: the 19:00 band is outside the + // minted window, but the projector already minted the evening pending + // row. It is a server placeholder, not a user action — it must not + // surface as an ad-hoc row with takenAt = null. + const morningOnly = bands.filter((b) => b.timeOfDay === "07:00"); + const rows = reconstructDoseHistory( + morningOnly, + [intake({ scheduledFor: at(19, 0) })], + at(14, 0), + ); + expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0); + expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00"]); + }); + + it("a skip with no band match still surfaces ad-hoc (deliberate action)", () => { + const morningOnly = bands.filter((b) => b.timeOfDay === "07:00"); + const rows = reconstructDoseHistory( + morningOnly, + [intake({ skipped: true, scheduledFor: at(19, 0) })], + at(14, 0), + ); + expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(1); + }); + + it("auto-missed stays missed even while the overdue window is still open", () => { + // The cron's verdict is authoritative — the clock does not resurrect it. + const rows = reconstructDoseHistory( + bands, + [intake({ autoMissed: true, scheduledFor: at(7, 0) })], + at(8, 30), + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + }); + it("returns rows in chronological order (slots + ad-hoc interleaved)", () => { const rows = reconstructDoseHistory( bands, diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 6a8d214e7..20f53a2a7 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -11,7 +11,11 @@ * Attribution rules: * - skipped / auto-missed / pending rows (no `takenAt`) bind to the slot * whose anchor equals their `scheduledFor` (±epsilon): these were written - * against a slot deliberately, so they annotate that slot; + * against a slot deliberately, so they annotate that slot. A pending row + * (neither skipped nor auto-missed) is status-derived by time — missed + * only past the slot's miss cutoff, upcoming until then — and a pending + * row matching no band at all is dropped (server-minted placeholder for + * a slot outside the queried window, not a user action); * - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`; * inside a band → that slot (on-time / late), outside every band → ad-hoc; * - each slot is claimed by at most one intake (first/best wins); extra @@ -85,14 +89,29 @@ export function reconstructDoseHistory( for (const i of anchored) { const band = nearestAnchorBand(i.scheduledFor, bands); - const status: DoseHistoryStatus = i.skipped ? "skipped" : "missed"; if (band && !claim.has(band)) { + // Status is time-aware for a pending row: the projector / reminder + // worker mint pending rows for every slot of the day up front, so a + // pending row on a slot whose miss cutoff hasn't passed is still + // takeable — it reads upcoming, not missed (mirrors the unfilled-slot + // branch below). A skip stays a skip; a cron-marked auto-miss stays + // missed regardless of the clock. + const status: DoseHistoryStatus = i.skipped + ? "skipped" + : i.autoMissed || now.getTime() > band.overdueEnd.getTime() + ? "missed" + : "upcoming"; claim.set(band, { intake: i, status }); - } else { - // A skip/miss with no matching slot (legacy off-grid) — surface it so - // nothing silently vanishes; tag it ad-hoc. + } else if (i.skipped || i.autoMissed) { + // A deliberate skip / cron-marked miss with no matching slot (legacy + // off-grid) — surface it so nothing silently vanishes; tag it ad-hoc. adHoc.push(i); } + // A pending row with no matching band is dropped: it is a server-minted + // placeholder (no user action) for a slot outside the queried band + // window — e.g. today's evening slot when the caller asked `to = now`. + // The band set is the source of truth for which slots exist in the + // window; emitting the placeholder would fabricate a phantom ad-hoc row. } for (const i of taken) { From b8c2b2e8c52d221e1a6ec95a895dfa8515490572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:00:11 +0200 Subject: [PATCH 02/11] fix(measurements): keep the mean-consolidation pass alive past unique collisions The nightly mean-consolidation upsert targets the externalId arbiter, but the measurement table carries a second full unique on (user, type, measured_at, source, sleep_stage) that also spans tombstones. Any row parked exactly on the canonical local-noon instant made the insert throw P2002, and because the global pass had no per-bucket fence the first poisoned day aborted consolidation for every remaining user and type. writeDay now probes the canonical slot inside the transaction and resolves the collision deterministically: rows the pass owns (tombstones, per-sample rows about to fold) shift to the next free second, foreign live rows keep their instant and the mean row yields instead. The runner gains an optional per-bucket error fence so one bad day is logged, counted as daysFailed, and skipped. --- src/lib/jobs/reminder-worker.ts | 2 +- .../__tests__/consolidate-daily-mean.test.ts | 189 +++++++++++++++++- .../measurements/consolidate-daily-mean.ts | 144 ++++++++++++- src/lib/measurements/consolidation-base.ts | 102 ++++++---- 4 files changed, 394 insertions(+), 43 deletions(-) diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index ed1f997c7..06ae8ae4f 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -3330,7 +3330,7 @@ export async function startReminderWorker() { }); workerLog( "info", - `[mean-consolidation] triggeredAt=${job.data.triggeredAt} usersScanned=${meanSummary.totals.usersScanned} daysConsolidated=${meanSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${meanSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${meanSummary.totals.dailyRowsUpserted}`, + `[mean-consolidation] triggeredAt=${job.data.triggeredAt} usersScanned=${meanSummary.totals.usersScanned} daysConsolidated=${meanSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${meanSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${meanSummary.totals.dailyRowsUpserted} daysFailed=${meanSummary.totals.daysFailed}`, ); } catch (err) { recordError(); diff --git a/src/lib/measurements/__tests__/consolidate-daily-mean.test.ts b/src/lib/measurements/__tests__/consolidate-daily-mean.test.ts index 33e5f8d36..7577aa940 100644 --- a/src/lib/measurements/__tests__/consolidate-daily-mean.test.ts +++ b/src/lib/measurements/__tests__/consolidate-daily-mean.test.ts @@ -12,6 +12,7 @@ import { consolidateDailyMean, meanBucketValue, } from "../consolidate-daily-mean"; +import { canonicalDailyTimestamp } from "../consolidation-tz"; import type { PerSampleRow } from "../drain-per-sample-cumulative"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; @@ -85,12 +86,16 @@ describe("consolidateDailyMean — drain flow (mocked Prisma)", () => { function buildPrismaMock(rowsByType: Record) { const upsert = vi.fn().mockResolvedValue({}); const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const update = vi.fn().mockResolvedValue({}); + // Canonical-slot probe inside the write transaction — defaults to an + // unoccupied slot (the pre-collision-fix happy path). + const txFindFirst = vi.fn().mockResolvedValue(null); const findManyMeasurement = vi.fn( async (args: { where: { type: string } }) => rowsByType[args.where.type] ?? [], ); const tx = { - measurement: { upsert, updateMany }, + measurement: { upsert, updateMany, update, findFirst: txFindFirst }, }; return { mock: { @@ -108,6 +113,8 @@ describe("consolidateDailyMean — drain flow (mocked Prisma)", () => { }, upsert, updateMany, + update, + txFindFirst, findManyMeasurement, }; } @@ -238,3 +245,183 @@ describe("consolidateDailyMean — drain flow (mocked Prisma)", () => { expect(call.where.measuredAt?.lt).toBeInstanceOf(Date); }); }); + +describe("consolidateDailyMean — canonical-slot collision (second unique index)", () => { + // Local-noon for 2026-05-01 in Europe/Berlin — the instant the mint + // targets and any colliding row occupies. + const noon = canonicalDailyTimestamp("2026-05-01", "Europe/Berlin"); + const targetExternalId = + "stats:HKQuantityTypeIdentifierWalkingSpeed:2026-05-01"; + + function buildPrismaMock(rowsByType: Record) { + const upsert = vi.fn().mockResolvedValue({}); + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const update = vi.fn().mockResolvedValue({}); + const txFindFirst = vi.fn().mockResolvedValue(null); + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => + rowsByType[args.where.type] ?? [], + ); + const tx = { + measurement: { upsert, updateMany, update, findFirst: txFindFirst }, + }; + return { + mock: { + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]), + }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (t: unknown) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient, + upsert, + updateMany, + update, + txFindFirst, + }; + } + + /** Slot probe answering `occupant` at noon and "free" everywhere else. */ + function occupySlot( + txFindFirst: ReturnType, + occupant: { id: string; externalId: string | null; deletedAt: Date | null }, + ) { + txFindFirst.mockImplementation( + async (args: { where: { measuredAt: Date } }) => + args.where.measuredAt.getTime() === noon.getTime() ? occupant : null, + ); + } + + it("steps a tombstone occupying the canonical instant aside and mints at noon (no P2002)", async () => { + const { mock, upsert, update, txFindFirst } = buildPrismaMock({ + WALKING_SPEED: [ + row("a", 1.0, "2026-05-01T08:00:00.000Z"), + row("b", 1.4, "2026-05-01T09:00:00.000Z"), + ], + }); + occupySlot(txFindFirst, { + id: "tomb-1", + externalId: "uuid-foreign-sample", + deletedAt: new Date("2026-05-02T00:00:00.000Z"), + }); + + const summary = await consolidateDailyMean(mock, { log: () => {} }); + + // The tombstone is shifted off the canonical instant (+1s)… + expect(update).toHaveBeenCalledTimes(1); + const updArg = update.mock.calls[0]?.[0] as { + where: { id: string }; + data: { measuredAt: Date }; + }; + expect(updArg.where.id).toBe("tomb-1"); + expect(updArg.data.measuredAt.getTime()).toBe(noon.getTime() + 1000); + + // …and the mean row keeps the local-noon anchor. + const upsertArg = upsert.mock.calls[0]?.[0] as { + create: { measuredAt: Date }; + update: { measuredAt: Date }; + }; + expect(upsertArg.create.measuredAt.getTime()).toBe(noon.getTime()); + expect(summary.totals.daysConsolidated).toBe(1); + expect(summary.totals.daysFailed).toBe(0); + }); + + it("shifts a per-sample row of the consolidation set off the canonical instant", async () => { + const { mock, update, txFindFirst } = buildPrismaMock({ + WALKING_SPEED: [ + // "a" sits exactly on local-noon and is part of the folded set. + row("a", 1.0, noon.toISOString(), "uuid-a"), + row("b", 1.4, "2026-05-01T09:00:00.000Z", "uuid-b"), + ], + }); + occupySlot(txFindFirst, { + id: "a", + externalId: "uuid-a", + deletedAt: null, + }); + + const summary = await consolidateDailyMean(mock, { log: () => {} }); + + // The live sample is folded anyway, so it yields the instant. + expect(update).toHaveBeenCalledTimes(1); + const updArg = update.mock.calls[0]?.[0] as { where: { id: string } }; + expect(updArg.where.id).toBe("a"); + expect(summary.totals.daysFailed).toBe(0); + }); + + it("yields the mean row's instant when a live foreign row holds the canonical slot", async () => { + const { mock, upsert, update, txFindFirst } = buildPrismaMock({ + WALKING_SPEED: [ + row("a", 1.0, "2026-05-01T08:00:00.000Z"), + row("b", 1.4, "2026-05-01T09:00:00.000Z"), + ], + }); + // Live row outside the consolidation set — not ours to move. + occupySlot(txFindFirst, { + id: "foreign-live", + externalId: "uuid-foreign-live", + deletedAt: null, + }); + + const summary = await consolidateDailyMean(mock, { log: () => {} }); + + expect(update).not.toHaveBeenCalled(); + const upsertArg = upsert.mock.calls[0]?.[0] as { + create: { measuredAt: Date }; + }; + expect(upsertArg.create.measuredAt.getTime()).toBe(noon.getTime() + 1000); + expect(summary.totals.daysFailed).toBe(0); + }); + + it("re-run path: a slot row already carrying the target externalId is updated in place", async () => { + const { mock, upsert, update, txFindFirst } = buildPrismaMock({ + WALKING_SPEED: [row("late", 1.2, "2026-05-01T18:00:00.000Z")], + }); + occupySlot(txFindFirst, { + id: "mean-1", + externalId: targetExternalId, + deletedAt: null, + }); + + await consolidateDailyMean(mock, { log: () => {} }); + + // No shift, no yield — the upsert's update branch owns the row. + expect(update).not.toHaveBeenCalled(); + const upsertArg = upsert.mock.calls[0]?.[0] as { + create: { measuredAt: Date }; + }; + expect(upsertArg.create.measuredAt.getTime()).toBe(noon.getTime()); + // Only the single slot probe ran — no free-instant search. + expect(txFindFirst).toHaveBeenCalledTimes(1); + }); + + it("a failing day bucket does not abort the pass — later buckets still consolidate", async () => { + const { mock, upsert } = buildPrismaMock({ + WALKING_SPEED: [row("a", 1.0, "2026-05-01T08:00:00.000Z")], + WALKING_ASYMMETRY: [ + row("c", 2.0, "2026-05-01T08:00:00.000Z", null, "%", "WALKING_ASYMMETRY"), + ], + }); + // First bucket's mint trips the unique index; the second succeeds. + upsert.mockRejectedValueOnce( + Object.assign(new Error("unique constraint failed"), { code: "P2002" }), + ); + const lines: string[] = []; + + const summary = await consolidateDailyMean(mock, { + log: (line) => lines.push(line), + }); + + expect(summary.totals.daysFailed).toBe(1); + expect(summary.totals.daysConsolidated).toBe(1); + // The surviving bucket reached the rollup recompute. + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); + // The failed day is reported with user/type/day context. + expect( + lines.some((l) => l.includes("failed") && l.includes("user-1")), + ).toBe(true); + }); +}); diff --git a/src/lib/measurements/consolidate-daily-mean.ts b/src/lib/measurements/consolidate-daily-mean.ts index 555649734..b2d999e1c 100644 --- a/src/lib/measurements/consolidate-daily-mean.ts +++ b/src/lib/measurements/consolidate-daily-mean.ts @@ -45,7 +45,11 @@ * production standalone image strips `tsx`. Modelled on the * `step-consolidation` boot-time converging-backfill pattern. */ -import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementType, + Prisma, + PrismaClient, +} from "@/generated/prisma/client"; import { dailyStatsExternalId, @@ -95,6 +99,12 @@ export interface MeanConsolidationSummary { daysConsolidated: number; perSampleRowsSoftDeleted: number; dailyRowsUpserted: number; + /** + * Day buckets whose write failed and was stepped over. A failed day + * keeps its per-sample rows live, so the next nightly run retries it; + * every other bucket of the pass still consolidates. + */ + daysFailed: number; }; } @@ -135,6 +145,51 @@ export function bucketMeanRows( return bucketRowsByDay(rows, tz, DAILY_STATS_PREFIX); } +/** + * Find the first instant at/after `base + 1s` that the full unique + * `(userId, type, measuredAt, source, sleepStage)` — NULLS NOT DISTINCT, + * so tombstones occupy it too — does not already hold for this + * (user, type, APPLE_HEALTH, sleepStage NULL) tuple. A slot held by the + * row carrying `selfExternalId` counts as free: that row is the one the + * caller's upsert is about to update in place, so re-using its instant + * converges across re-runs instead of drifting one second per night. + * Bounded probe; the per-day failure boundary in `runConsolidation` + * absorbs the pathological exhaustion case. + */ +async function nextFreeInstant( + tx: Prisma.TransactionClient, + input: { + userId: string; + type: MeasurementType; + base: Date; + selfExternalId: string | null; + }, +): Promise { + for (let offsetSeconds = 1; offsetSeconds <= 60; offsetSeconds++) { + const candidate = new Date(input.base.getTime() + offsetSeconds * 1000); + const occupant = await tx.measurement.findFirst({ + where: { + userId: input.userId, + type: input.type, + source: "APPLE_HEALTH", + measuredAt: candidate, + sleepStage: null, + }, + select: { externalId: true }, + }); + if ( + occupant === null || + (input.selfExternalId !== null && + occupant.externalId === input.selfExternalId) + ) { + return candidate; + } + } + throw new Error( + `no free measuredAt slot within 60s after ${input.base.toISOString()}`, + ); +} + /** * Run the daily-mean consolidation. Idempotent — re-invocation after a * successful pass reports zero days because the per-sample rows are @@ -156,10 +211,11 @@ export async function consolidateDailyMean( daysConsolidated: 0, perSampleRowsSoftDeleted: 0, dailyRowsUpserted: 0, + daysFailed: 0, }, }; - const { usersScanned } = await runConsolidation({ + const { usersScanned, daysFailed } = await runConsolidation({ prismaClient, options, types: HIGH_FREQUENCY_MEAN_TYPES, @@ -205,7 +261,71 @@ export async function consolidateDailyMean( const unit = dayRows[0]?.unit ?? "unknown"; let removed = 0; await pc.$transaction(async (tx) => { - // Mint / refresh the canonical daily-mean row first. The unique + // The upsert below arbiters on (userId, type, source, externalId), + // but the Measurement model carries a SECOND full unique — + // (userId, type, measuredAt, source, sleepStage), NULLS NOT + // DISTINCT, covering tombstones (schema.prisma, migration 0055). + // Any row sitting exactly on the canonical local-noon instant with + // a DIFFERENT externalId — live or soft-deleted — trips that index + // on the upsert's create path (P2002) and rolls the whole day + // back. Resolve the slot deterministically before minting: + // 1. Slot row carries the TARGET externalId → it IS the + // canonical daily row (re-run path); the upsert updates it in + // place, no conflict. + // 2. Slot row is a tombstone, or a per-sample row this very pass + // is about to soft-delete → it has no live claim on the + // instant; step it aside to the next free second so the + // canonical row keeps the local-noon anchor. + // 3. Slot row is a live foreign row outside the consolidation + // set (it stays user-visible and is not ours to move) → it + // keeps its instant; the mean row yields and mints on the + // next free second after local-noon instead. + const slotRow = await tx.measurement.findFirst({ + where: { + userId, + type, + source: "APPLE_HEALTH", + measuredAt: canonicalTimestamp, + sleepStage: null, + }, + select: { id: true, externalId: true, deletedAt: true }, + }); + + let mintAt = canonicalTimestamp; + if (slotRow !== null && slotRow.externalId !== externalId) { + const yieldsToMean = + slotRow.deletedAt !== null || sourceRowIds.includes(slotRow.id); + if (yieldsToMean) { + // `selfExternalId: null` — the shifted row must land on a + // strictly free instant; sharing one with the canonical row + // (e.g. a mean row that yielded on an earlier run and still + // sits at +1s) would re-trip the unique. + await tx.measurement.update({ + where: { id: slotRow.id }, + data: { + measuredAt: await nextFreeInstant(tx, { + userId, + type, + base: canonicalTimestamp, + selfExternalId: null, + }), + }, + }); + } else { + // `selfExternalId: externalId` — an instant already held by + // the existing mean row (yielded on an earlier run) counts as + // free, so the upsert re-targets it instead of drifting one + // second further every night. + mintAt = await nextFreeInstant(tx, { + userId, + type, + base: canonicalTimestamp, + selfExternalId: externalId, + }); + } + } + + // Mint / refresh the canonical daily-mean row. The unique // index (userId, type, source, externalId) makes the upsert // idempotent across re-runs. Built field-by-field (no spread) // per the no-mass-assignment convention. @@ -224,12 +344,12 @@ export async function consolidateDailyMean( value: reducedValue, unit, source: "APPLE_HEALTH", - measuredAt: canonicalTimestamp, + measuredAt: mintAt, externalId, }, update: { value: reducedValue, - measuredAt: canonicalTimestamp, + measuredAt: mintAt, deletedAt: null, }, }); @@ -285,12 +405,24 @@ export async function consolidateDailyMean( `[mean-consolidation] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`, ); }, + // Per-day failure boundary: one poisoned day (e.g. a residual + // unique-index collision the slot resolution could not absorb) must + // not abort the global nightly pass — every later user / type / day + // still consolidates, and the failed day's per-sample rows stay live + // for the next run to retry. + onBucketError: ({ userId, type, dateKey, error }) => { + const reason = error instanceof Error ? error.message : String(error); + log( + `[mean-consolidation] user=${userId} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`, + ); + }, }); summary.totals.usersScanned = usersScanned; + summary.totals.daysFailed = daysFailed; log( - `[mean-consolidation] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`, + `[mean-consolidation] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed}${options.dryRun ? " (dry-run)" : ""}`, ); return summary; diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index bd06f6d1b..94068600b 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -231,6 +231,22 @@ export interface ConsolidationParams { }) => void; /** Per-user log line, COMPLETE — optional. */ onUserComplete?: (input: { userId: string; tz: string; dryRun: boolean }) => void; + /** + * Per-day failure boundary — optional. When supplied, an error thrown + * while reducing / writing / recording ONE day bucket is reported here + * and the walk continues with the next bucket, so a single poisoned day + * (e.g. a unique-index collision on the mint) can no longer abort the + * whole global pass and strand every later user / type / day. When + * omitted, errors propagate exactly as before — drains whose `writeDay` + * classifies its own errors (and relies on the rethrow reaching pg-boss + * for a retry) keep their established semantics. + */ + onBucketError?: (input: { + userId: string; + type: TType; + dateKey: string; + error: unknown; + }) => void; } const DEFAULT_SCAN_SELECT: Prisma.MeasurementSelect = { @@ -245,7 +261,9 @@ const DEFAULT_SCAN_SELECT: Prisma.MeasurementSelect = { * Drive one consolidation pass. Walks `users → types → days`, scans live * source rows inside the grace window, buckets them, reduces each day, * and delegates the per-day mint + delete to `writeDay`. Returns the - * number of users scanned so the caller can seed its summary totals. + * number of users scanned (so the caller can seed its summary totals) + * plus the number of day buckets absorbed by `onBucketError` (0 when the + * boundary is not supplied). * * Idempotency, the grace-window cutoff, and the "skip already-collapsed * rows" predicate are all owned here; the divergent reducer / delete / @@ -253,11 +271,12 @@ const DEFAULT_SCAN_SELECT: Prisma.MeasurementSelect = { */ export async function runConsolidation( params: ConsolidationParams, -): Promise<{ usersScanned: number; dryRun: boolean }> { +): Promise<{ usersScanned: number; dryRun: boolean; daysFailed: number }> { const { prismaClient, options } = params; const dryRun = options.dryRun ?? false; const cutoffAt = resolveCutoffInstant(options.cutoffHours); const scanSelect = params.scanSelect ?? DEFAULT_SCAN_SELECT; + let daysFailed = 0; const users = await loadConsolidationUsers(prismaClient, options.userId); @@ -296,55 +315,68 @@ export async function runConsolidation( for (const [dateKey, dayRows] of byDay) { if (dayRows.length === 0) continue; - const reducedValue = params.reduce(dayRows); - const canonicalTimestamp = canonicalDailyTimestamp(dateKey, tz); - const externalId = params.dailyStatsExternalId(hkIdentifier, dateKey); + try { + const reducedValue = params.reduce(dayRows); + const canonicalTimestamp = canonicalDailyTimestamp(dateKey, tz); + const externalId = params.dailyStatsExternalId(hkIdentifier, dateKey); - const shouldMint = params.onBucket - ? await params.onBucket({ + const shouldMint = params.onBucket + ? await params.onBucket({ + prismaClient, + userId: user.id, + type, + dateKey, + dayRows, + reducedValue, + canonicalTimestamp, + externalId, + }) + : true; + + let outcome: DayWriteOutcome | null = null; + if (!dryRun) { + const sourceRowIds = dayRows.map((r) => r.id); + outcome = await params.writeDay({ prismaClient, userId: user.id, type, - dateKey, - dayRows, - reducedValue, - canonicalTimestamp, externalId, - }) - : true; + canonicalTimestamp, + reducedValue, + dayRows, + sourceRowIds, + shouldMint, + }); + } - let outcome: DayWriteOutcome | null = null; - if (!dryRun) { - const sourceRowIds = dayRows.map((r) => r.id); - outcome = await params.writeDay({ - prismaClient, + params.recordBucket({ userId: user.id, type, - externalId, - canonicalTimestamp, - reducedValue, + dateKey, dayRows, - sourceRowIds, + reducedValue, + canonicalTimestamp, + externalId, shouldMint, + outcome, + }); + } catch (err) { + // No boundary supplied → preserve the historical abort-the-run + // behaviour for the drains that classify errors in `writeDay`. + if (!params.onBucketError) throw err; + daysFailed += 1; + params.onBucketError({ + userId: user.id, + type, + dateKey, + error: err, }); } - - params.recordBucket({ - userId: user.id, - type, - dateKey, - dayRows, - reducedValue, - canonicalTimestamp, - externalId, - shouldMint, - outcome, - }); } } params.onUserComplete?.({ userId: user.id, tz, dryRun }); } - return { usersScanned: users.length, dryRun }; + return { usersScanned: users.length, dryRun, daysFailed }; } From 76012bf8e24049ea3419686b8b7e297dcec07115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:00:26 +0200 Subject: [PATCH 03/11] chore: scrub instance details and personal data from committed artefacts Blood-pressure fixtures in the in-target integration suite were copied from live readings; they are now synthetic with the same in/out split so every assertion holds. Hard-coded operator hostnames move behind env vars (DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via the public repository URL, the OpenAPI servers entry points at a neutral example host, and remaining workflow, docs and comment references to the operator's instance or working notes are generalised. The compose whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made the deploy webhook unusable on compose installs. --- .env.production.example | 3 + .github/workflows/docker-publish.yml | 18 ++--- docker-compose.yml | 7 ++ docs/apple-store-connect-checklist.md | 32 ++++---- eslint.config.mjs | 12 +-- scripts/assert-deploy.ts | 33 ++++++-- scripts/env-manifest.json | 4 + .../proxy-csp-hsts-hardening.test.ts | 2 +- .../apple-app-site-association/route.ts | 4 +- src/app/api/internal/deploy-webhook/route.ts | 7 +- src/app/privacy/page.tsx | 33 ++++---- .../admin/__tests__/admin-shell.test.tsx | 4 +- src/components/i18n/maintainership-banner.tsx | 2 +- src/lib/ai/codex-client.ts | 2 +- src/lib/openapi/registry.ts | 5 +- src/lib/tz/resolver.ts | 5 +- tests/integration/bp-in-target.test.ts | 77 ++++++++++--------- 17 files changed, 145 insertions(+), 105 deletions(-) diff --git a/.env.production.example b/.env.production.example index 0cfdc6a33..e4f73447e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -65,6 +65,9 @@ API_TOKEN_HMAC_KEY="" # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- # DEPLOY_WEBHOOK_SECRET="" +# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in +# deploy-failure notifications. When unset the notification omits the link. +# DEPLOY_LOGS_URL="" # ----------------------------------------------------------------------------- diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b92a36a19..45d38017b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -313,15 +313,15 @@ jobs: run: | docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' - # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has - # the new image. Removes the manual SSH + force-pull recipe documented - # in `.planning/phase-8-report.md`. + # v1.4.15: trigger the Coolify deploy automatically once GHCR has + # the new image. Replaces the former manual SSH + force-pull recipe + # on the deployment host. # # Runs only on the publish path (push to `main` or to a `v*` tag), never # on PRs. `continue-on-error: true` because the image is *already* # published to GHCR by the time we reach this step — a Coolify-side - # outage must not retroactively fail the build. Marc still has the - # manual recipe as fallback. + # outage must not retroactively fail the build. The maintainer still + # has the manual recipe as fallback. # # Required GitHub repository secrets (set by the maintainer once): # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false @@ -377,8 +377,8 @@ jobs: # new digest. The webhook fires inside that window, so # Coolify pulls "latest" while the edge still serves the # prior digest, gets "no change", and returns success. - # See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md` - # for the full root-cause + remediation note. + # Waiting out the propagation window before firing the + # webhook closes that gap. echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…" sleep 90 # v1.4.22 C3 — force=true tells Coolify to skip its image-cache @@ -405,8 +405,8 @@ jobs: echo "Coolify response (HTTP $status):" echo "$body" if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then - echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md." + echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)." exit 1 fi - echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md." + echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)." echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status." diff --git a/docker-compose.yml b/docker-compose.yml index 5cc35e198..1f382c67a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,13 @@ services: # container (the `environment:` block is a whitelist). WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" + # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates + # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the + # deployment-dashboard URL (e.g. the Coolify UI) linked in + # deploy-failure notifications. Listed here so operator values in + # .env reach the container (the `environment:` block is a whitelist). + DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" + DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" # TLS leaf SPKI-change alarm baseline (optional). The native client # pins the served TLS leaf; this is the known-good pin set the monitor # alarms against on a rotation. Comma-separated for the dual-pin diff --git a/docs/apple-store-connect-checklist.md b/docs/apple-store-connect-checklist.md index 323a419b6..88806d1d1 100644 --- a/docs/apple-store-connect-checklist.md +++ b/docs/apple-store-connect-checklist.md @@ -1,7 +1,7 @@ --- file: docs/apple-store-connect-checklist.md purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata -audience: Marc (the maintainer) before submitting to App Store +audience: the maintainer, before submitting to App Store estimated_read_time: 15 min last_updated: 2026-05-15 --- @@ -10,7 +10,7 @@ last_updated: 2026-05-15 ## TL;DR -Three submission gates that need Marc's hand at ASC: +Three submission gates that need the maintainer's hand at ASC: 1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix)) 2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2) 3. Standard metadata (name, description, screenshots, categories, age rating) @@ -29,7 +29,7 @@ Plus pre-submit final checks. **What to check**: - [ ] Check "No" on the medical-device toggle - [ ] Add the description above to the medical-device-justification field if Apple shows one -- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary +- [ ] Cross-reference the in-app `/privacy` page (https:///privacy) §7 MDR-boundary **Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for. @@ -37,7 +37,7 @@ Plus pre-submit final checks. **Where**: App Store Connect → App Privacy → "Edit" the privacy choices. -**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose. +**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose. ### Data categories to declare @@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account - Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit - Purchases - Financial Info -- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude. +- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client. - Advertising Data - Other Usage Data @@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account - [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach") - [ ] Primary category: Health & Fitness - [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it) -- [ ] Content rights: confirm Marc owns the app rights +- [ ] Content rights: confirm the maintainer owns the app rights - [ ] Age rating: 17+ (because Sensitive Health Info content) ### Description @@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account - [ ] iPad screenshots if supporting iPad. ### URLs -- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed) -- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix) +- [ ] Marketing URL: https:// (or the marketing site once the landing repo's v1.4.25 is deployed) +- [ ] Privacy Policy URL: **https:///privacy** (ships with v1.4.25.1 hotfix) - [ ] Support URL: https://github.com/MBombeck/HealthLog/issues ### App Review Information -- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields +- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields - [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim. - [ ] Sign-in required: yes (provide demo credentials) ### Pricing + Availability - [ ] Free -- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch) +- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch) ## Section 4 — Pre-Submit Final Check Run through this list: -- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy) +- [ ] Privacy Policy URL responds 200 (curl https:///privacy) - [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2 - [ ] Demo account works on the demo server - [ ] iOS app's TestFlight build is on the latest commit -- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`) -- [ ] Marc's Apple Developer Program subscription is active +- [ ] The maintainer has the APNs .p8 backed up to a password manager +- [ ] The maintainer's Apple Developer Program subscription is active ## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific) - Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer -- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0 -- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag -- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches +- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0 +- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag +- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches ## Section 6 — Post-Submit / If Rejected diff --git a/eslint.config.mjs b/eslint.config.mjs index 74782b5a5..a1d07e8eb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,8 +13,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", - // v1.4.41 — ignore Claude Code session worktrees so a mid-marathon - // worktree on an older commit can't poison `pnpm lint` locally. + // v1.4.41 — ignore local tool worktrees so a worktree on an older + // commit can't poison `pnpm lint` locally. // CI never sees this path; the rule is for the dev environment. ".claude/**", // The project-local ESLint rule plugin is CommonJS (require/module @@ -22,11 +22,11 @@ const eslintConfig = defineConfig([ // is not linted by the app's TypeScript ruleset. "eslint-plugins/**", ]), - // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array + // v1.4.41 — custom rule that flags any bare-array // `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the - // files the queryKeys factory currently guards. Promotes the - // test-guard substitute from v1.4.40 W-RSC to a real ESLint rule - // so IDE + CI both fail fast on a factory bypass. See + // files the queryKeys factory currently guards. Promotes the earlier + // v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI + // both fail fast on a factory bypass. See // `eslint-plugins/healthlog/queryKey-factory.js` for the // whitelist + rationale. { diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts index ea1e677d7..554d450ee 100644 --- a/scripts/assert-deploy.ts +++ b/scripts/assert-deploy.ts @@ -13,7 +13,11 @@ * Run it after every deploy — the queued-deploy status is not the source * of truth, the served version is: * - * pnpm dlx tsx scripts/assert-deploy.ts + * HEALTHLOG_PROD_URL=https://healthlog.example.com \ + * pnpm dlx tsx scripts/assert-deploy.ts + * + * The prod target's base URL comes from HEALTHLOG_PROD_URL (required + * unless --only=demo). * * Examples: * @@ -36,8 +40,15 @@ interface Target { url: string; } -const TARGETS: Record = { - prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" }, +// The production base URL is instance-specific — there is no meaningful +// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g. +// `https://healthlog.example.com`) before asserting the prod target. +const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, ""); + +const TARGETS: Record = { + prod: PROD_BASE_URL + ? { name: "prod", url: `${PROD_BASE_URL}/api/version` } + : undefined, demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" }, }; @@ -140,14 +151,26 @@ async function main(): Promise { let selected: Target[]; if (only) { + if (!(only in TARGETS)) { + console.error(`unknown --only target: ${only} (use prod or demo)`); + process.exit(2); + } const picked = TARGETS[only]; if (!picked) { - console.error(`unknown --only target: ${only} (use prod or demo)`); + console.error( + `target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`, + ); process.exit(2); } selected = [picked]; } else { - selected = Object.values(TARGETS); + if (!TARGETS.prod) { + console.error( + "HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo", + ); + process.exit(2); + } + selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t)); } console.log( diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 08c4fbea2..ab266a882 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -87,6 +87,10 @@ { "name": "DEPLOY_WEBHOOK_SECRET", "purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars." + }, + { + "name": "DEPLOY_LOGS_URL", + "purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link." } ] }, diff --git a/src/__tests__/proxy-csp-hsts-hardening.test.ts b/src/__tests__/proxy-csp-hsts-hardening.test.ts index 39023d124..2624e1f1d 100644 --- a/src/__tests__/proxy-csp-hsts-hardening.test.ts +++ b/src/__tests__/proxy-csp-hsts-hardening.test.ts @@ -6,7 +6,7 @@ import { NextRequest } from "next/server"; * needed to harden against first-visit MITM and to scope the * Withings backend host to its surface only. * - * - HSTS now carries `; preload` so `healthlog.bombeck.io` is + * - HSTS now carries `; preload` so the served host is * eligible for the Chromium HSTS preload list. * - The `wbsapi.withings.net` `connect-src` entry only ships on * the Withings surfaces (`/settings/integrations/withings/*` diff --git a/src/app/.well-known/apple-app-site-association/route.ts b/src/app/.well-known/apple-app-site-association/route.ts index 5cddf1821..2f692e636 100644 --- a/src/app/.well-known/apple-app-site-association/route.ts +++ b/src/app/.well-known/apple-app-site-association/route.ts @@ -21,8 +21,8 @@ import { NextResponse } from "next/server"; * it to devices, so the origin only needs to respond reliably; Apple's * one-hour CDN TTL pairs with the `Cache-Control` directive below. * - * Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev` - * (demo) share this handler — the response is host-independent. + * Every host serving this app (an operator's instance, the public demo) + * shares this handler — the response is host-independent. */ const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app"; diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 12baf3bb0..d60655740 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -9,7 +9,7 @@ import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-loca import { prisma } from "@/lib/db"; /** - * Deploy-status webhook (phase C2 / v1.4.15). + * Deploy-status webhook (v1.4.15). * * Coolify (Settings → Notifications → Webhook) calls this endpoint after * every deploy attempt. We verify the request via a shared secret in the @@ -135,7 +135,10 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { application: event.applicationName, error: event.error ?? "", deployment: event.deploymentUuid ?? "", - logsUrl: "https://apps-01.bombeck.io", + // Optional: operators can point this at their deployment + // dashboard (e.g. the Coolify UI). Falls back to an empty + // string so the body template still composes cleanly. + logsUrl: process.env.DEPLOY_LOGS_URL ?? "", }, metadata: { source: "deploy-webhook", diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 511b9f588..737aea725 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; * * 1. **Apple App Store Connect** requires a publicly reachable Privacy- * Policy URL before the iOS native app's first submission. The URL - * registered with ASC is `https://healthlog.bombeck.io/privacy`. + * registered with ASC is this route on the maintainer's instance. * 2. **GDPR / DSGVO Art. 13 / 14 + Art. 15-22** — data-subject rights * enumerated with concrete in-app routes the user can hit. * 3. **EU MDR 2017/745 + MDCG 2021-24** — the boundary "HealthLog is @@ -342,14 +342,12 @@ export default function PrivacyPage() { > github.com/MBombeck/HealthLog {" "} - veröffentlicht. Diese Erklärung beschreibt, wie die unter{" "} + veröffentlicht. Diese Erklärung beschreibt, wie die + Instanz, über die Sie diese Seite abrufen, und die + zugehörige iOS-Anwendung HealthLog for iOS{" "} + (Bundle-ID{" "} - healthlog.bombeck.io - {" "} - betriebene Instanz und die zugehörige iOS-Anwendung{" "} - HealthLog for iOS (Bundle-ID{" "} - - io.bombeck.healthlog + dev.healthlog.app ) personenbezogene Daten verarbeiten.

@@ -359,7 +357,7 @@ export default function PrivacyPage() { Deutschland). Eigenständig betriebene, selbst-gehostete Instanzen unterliegen der Datenschutzerklärung ihres jeweiligen Betreibers; dieses Dokument gilt ausschließlich - für die genannte Domain. + für diese Instanz.

Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11 @@ -381,14 +379,11 @@ export default function PrivacyPage() { github.com/MBombeck/HealthLog {" "} under the GNU Affero General Public License v3.0. This - policy describes how the hosted instance at{" "} + policy describes how the instance serving this page and + the companion iOS application HealthLog for iOS{" "} + (bundle identifier{" "} - healthlog.bombeck.io - {" "} - and the companion iOS application{" "} - HealthLog for iOS (bundle identifier{" "} - - io.bombeck.healthlog + dev.healthlog.app ) handle personal data.

@@ -397,7 +392,7 @@ export default function PrivacyPage() { this instance (an individual based in Germany). Self- hosted deployments controlled by a different operator are governed by that operator's own privacy policy; this - document applies only to the hostname above. + document applies only to this instance.

For questions or data-subject requests, see section 11 @@ -1211,8 +1206,8 @@ export default function PrivacyPage() { /> ", () => { // The 13-section strip is ~1700 CSS px wide. Without // `no-scrollbar` (defined in `globals.css`) Chromium/WebKit paint // an always-on horizontal scrollbar at the top of every admin - // page. Production probe at https://healthlog.bombeck.io/admin/ - // api-tokens at Pixel-5 viewport pins this as the *only* element + // page. A live probe of /admin/api-tokens at a Pixel-5 viewport + // pins this as the *only* element // with `overflow-x:auto AND scrollWidth > clientWidth`, so the // class is the load-bearing fix. const html = renderShell({ active: "api-tokens" }); diff --git a/src/components/i18n/maintainership-banner.tsx b/src/components/i18n/maintainership-banner.tsx index 2779d0783..f1ce67ed1 100644 --- a/src/components/i18n/maintainership-banner.tsx +++ b/src/components/i18n/maintainership-banner.tsx @@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; * v1.4.25 W9e — notice strip for AI-initial locales. * * HealthLog ships German + English as maintainer-curated translations - * (the maintainer, Marc, owns both bodies). French, Spanish, Italian + * (the maintainer owns both bodies). French, Spanish, Italian * and Polish ship as AI-initial translations — they pass the * locale-integrity test (full key parity, no empty values, no TODOs) * but the prose has not been hand-reviewed for register, regional diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 1c0c6b375..0c9ccaea8 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -92,7 +92,7 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean { } const ORIGINATOR = "healthlog"; -const USER_AGENT = "HealthLog/1.0 (+https://healthlog.bombeck.io)"; +const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)"; interface CodexClientConfig { accessToken: string; diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 77cebceb3..16ff106df 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -52,7 +52,10 @@ const openApiBase: Pick< contact: { name: "HealthLog", url: "https://healthlog.dev" }, }, servers: [ - { url: "https://healthlog.bombeck.io", description: "Production" }, + { + url: "https://healthlog.example.com", + description: "Your self-hosted instance", + }, { url: "http://localhost:3000", description: "Local dev" }, ], tags: [ diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index e3191f4e6..6274e399c 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -65,8 +65,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { * Format a Date as `YYYY-MM-DD` in `Europe/Berlin`. The seven Insight * status helpers (`bmi`, `blood-pressure`, `weight`, `pulse`, `mood`, * `medication-compliance`, `general`) all key their per-day cache on - * this — Marc's primary tenant runs in Berlin and the cache action - * row reads identically across helpers when the formatter lives here. + * this — the maintainer's primary tenant runs in Berlin and the cache + * action row reads identically across helpers when the formatter lives + * here. * * For per-user surfaces, prefer `userDayKey(date, tz)` from `./format`. * This helper exists for the cache-key path where the day-bucket is diff --git a/tests/integration/bp-in-target.test.ts b/tests/integration/bp-in-target.test.ts index 0d5f8e25a..19ce8b601 100644 --- a/tests/integration/bp-in-target.test.ts +++ b/tests/integration/bp-in-target.test.ts @@ -1,8 +1,8 @@ /** * Integration regression for the "BD-Zielbereich" / BP-in-target tile. * - * Marc reported (v1.4.16 marathon) that the dashboard tile rendered - * 0 % despite multiple BP readings clearly inside the well-controlled + * A v1.4.16 bug report showed the dashboard tile rendering 0 % + * despite multiple BP readings clearly inside the well-controlled * range. v1.4.15 A4 had restructured the calculation but kept the * ESH 2023 narrow-band semantics (`sysLow <= sys <= sysHigh AND diaLow * <= dia <= diaHigh`) — which collapses to 0 % for any user whose @@ -73,15 +73,15 @@ describe("BP-in-target % — production data shape", () => { it("computes a non-zero % for a normotensive user born under 65", async () => { const prisma = getPrismaClient(); - // Marc's actual production readings (anonymised but timestamps + values - // verbatim so a future reviewer can re-derive the expected count by - // hand from the test fixture). DOB: 1985-07-09 — under 65, so target - // is sysHigh = 129, diaHigh = 79. + // Synthetic but representative normotensive readings for a user + // under 65, annotated IN/OUT so a future reviewer can re-derive the + // expected count by hand from the test fixture. DOB 1980-01-15 — + // under 65, so target is sysHigh = 129, diaHigh = 79. const user = await prisma.user.create({ data: { username: "bp-in-target-fixture", email: "bp-in-target@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -90,16 +90,16 @@ describe("BP-in-target % — production data shape", () => { dia: number; measuredAt: string; }> = [ - { sys: 117, dia: 79, measuredAt: "2026-05-08T07:38:22Z" }, // IN - { sys: 122, dia: 86, measuredAt: "2026-05-03T21:22:02Z" }, // OUT (dia) - { sys: 108, dia: 76, measuredAt: "2026-05-03T05:51:45Z" }, // IN - { sys: 106, dia: 73, measuredAt: "2026-05-03T05:50:55Z" }, // IN - { sys: 127, dia: 86, measuredAt: "2026-04-20T05:57:42Z" }, // OUT (dia) - { sys: 115, dia: 78, measuredAt: "2026-04-18T06:59:29Z" }, // IN - { sys: 108, dia: 75, measuredAt: "2026-04-16T05:24:51Z" }, // IN - { sys: 124, dia: 82, measuredAt: "2026-04-15T05:34:35Z" }, // OUT (dia) - { sys: 126, dia: 80, measuredAt: "2026-04-15T05:33:44Z" }, // OUT (dia) - { sys: 133, dia: 95, measuredAt: "2026-04-15T20:52:26Z" }, // OUT (both) + { sys: 118, dia: 78, measuredAt: "2026-05-08T07:40:00Z" }, // IN + { sys: 124, dia: 85, measuredAt: "2026-05-03T21:20:00Z" }, // OUT (dia) + { sys: 110, dia: 75, measuredAt: "2026-05-03T06:00:00Z" }, // IN + { sys: 104, dia: 72, measuredAt: "2026-05-03T05:50:00Z" }, // IN + { sys: 126, dia: 84, measuredAt: "2026-04-20T06:00:00Z" }, // OUT (dia) + { sys: 116, dia: 77, measuredAt: "2026-04-18T07:00:00Z" }, // IN + { sys: 109, dia: 74, measuredAt: "2026-04-16T05:25:00Z" }, // IN + { sys: 123, dia: 83, measuredAt: "2026-04-15T05:35:00Z" }, // OUT (dia) + { sys: 127, dia: 81, measuredAt: "2026-04-15T05:34:00Z" }, // OUT (dia) + { sys: 134, dia: 94, measuredAt: "2026-04-15T20:50:00Z" }, // OUT (both) ]; // Seed both sys and dia rows at the same `measuredAt` so the helper's @@ -154,7 +154,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-empty-fixture", email: "bp-empty@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -184,7 +184,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-sys-only-fixture", email: "bp-sys-only@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -224,7 +224,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-hypo-fixture", email: "bp-hypo@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -272,9 +272,9 @@ describe("BP-in-target % — production data shape", () => { describe("BP-in-target % — windowed (7-day + 30-day) — production data shape", () => { /** - * v1.4.18 A1 regression — Marc's BD-Zielbereich tile rendered the + * v1.4.18 A1 regression — the BD-Zielbereich tile rendered the * 30-day headline (50 %) but `7T: —` and `30T: —` placeholders even - * though he had paired BP readings in both windows. Root cause was + * though the user had paired BP readings in both windows. Root cause was * that the API only returned a single `bpInTargetPct`; the tile got * `avg7={null}, avg30={null}` and rendered the dash fallback. The fix * surfaces both windows from `computeBpInTargetWindows()` against the @@ -290,7 +290,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-windows-fixture", email: "bp-windows@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -309,12 +309,12 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape // last30Days: 15/29 = 52 % (rounded). const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // 7-day window (4 IN, 3 OUT) - { daysAgo: 0.5, sys: 117, dia: 79 }, // IN - { daysAgo: 1.5, sys: 122, dia: 86 }, // OUT (dia) - { daysAgo: 2.5, sys: 108, dia: 76 }, // IN + { daysAgo: 0.5, sys: 118, dia: 78 }, // IN + { daysAgo: 1.5, sys: 124, dia: 85 }, // OUT (dia) + { daysAgo: 2.5, sys: 110, dia: 75 }, // IN { daysAgo: 3.5, sys: 145, dia: 95 }, // OUT (both) - { daysAgo: 4.5, sys: 115, dia: 78 }, // IN - { daysAgo: 5.5, sys: 124, dia: 82 }, // OUT (dia) + { daysAgo: 4.5, sys: 116, dia: 77 }, // IN + { daysAgo: 5.5, sys: 123, dia: 83 }, // OUT (dia) { daysAgo: 6.5, sys: 125, dia: 75 }, // IN // older 7-30-day window (alternating, 11 IN / 11 OUT) ...Array.from({ length: 22 }, (_, i) => { @@ -377,10 +377,11 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape }); /** - * v1.4.19 A1 regression — Marc reported the tile shows EXACTLY 50 % - * on 7T, 30T, AND the headline ("total"). With his real production - * shape (572 paired readings since 2022, recent 30d ≈ 50 %, all-time - * ≈ 11 %) the headline cannot legitimately be 50 %. Root cause: the + * v1.4.19 A1 regression — a bug report showed the tile rendering + * EXACTLY 50 % on 7T, 30T, AND the headline ("total"). With a long + * reading history (hundreds of paired readings over several years, + * recent 30d ≈ 50 %, all-time far lower) the headline cannot + * legitimately be 50 %. Root cause: the * analytics route routed `bpInTargetPct = windows.last30Days?.pct`, * making the headline a literal copy of `30T`. The fix returns a * third `allTime` window that the route now uses for the headline. @@ -397,14 +398,14 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-three-windows-fixture", email: "bp-three-windows@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); const now = new Date(); const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. - { daysAgo: 1.5, sys: 117, dia: 79 }, // IN + { daysAgo: 1.5, sys: 118, dia: 78 }, // IN { daysAgo: 5.5, sys: 145, dia: 95 }, // OUT // 7-30 days ago: 4 IN, 4 OUT (alternating). { daysAgo: 8, sys: 122, dia: 75 }, // IN @@ -466,7 +467,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape expect(windows.allTime!.pct).toBe(Math.round((5 / 40) * 100)); // The smoking gun: even when 7d AND 30d are 50 %, all-time is NOT - // 50 % once older history is present. Marc's prod tile pinned to + // 50 % once older history is present. The reported tile pinned to // 50/50/50 because the route routed the headline through `last30Days` // — the algorithmic pin the brief warned about. expect(windows.allTime!.pct).not.toBe(windows.last30Days!.pct); @@ -485,7 +486,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-no-recent-fixture", email: "bp-no-recent@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -552,7 +553,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { username, email: `${username}@example.test`, role: "USER", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); const session = await prisma.session.create({ @@ -576,7 +577,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { // 30d both ~50 %, all-time ~13 %. const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. - { daysAgo: 1.5, sys: 117, dia: 79 }, + { daysAgo: 1.5, sys: 118, dia: 78 }, { daysAgo: 5.5, sys: 145, dia: 95 }, // 7-30 days ago: 4 IN, 4 OUT. { daysAgo: 8, sys: 122, dia: 75 }, From 0761ce2346b6446161a29927cdb3ff23f24b2bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:17:38 +0200 Subject: [PATCH 04/11] fix(medications): widen write-path band minting to cover day-scale tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path minted attribution bands over a fixed ±1 day around the take, so a weekly or rolling dose taken two to four days late — inside its own overdue tail — never found its anchor and was stored ad hoc while the slot stayed missed. Day-scale schedules now mint over the full on-time-plus-overdue reach derived from the configured dose windows; minute-scale schedules keep the ±1 day probe. --- .../__tests__/attribute-intake.test.ts | 74 +++++++++++++++++++ .../scheduling/attribute-intake.ts | 67 +++++++++++------ 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts index 84f2e9ba9..9b7319e82 100644 --- a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -123,6 +123,80 @@ describe("attributeTakenToSlot — PRN / unscheduled", () => { }); }); +describe("attributeTakenToSlot — weekly day-scale late tail", () => { + // Weekly Monday 08:00 (legacy daysOfWeek shape). DAY (2026-06-08) is a + // Monday, so the anchor is Monday 08:00 and the band spans Sun 08:00 → + // Tue 08:00 on-time plus the 4-day overdue tail to Sat 08:00. + const weekly = med([schedule({ daysOfWeek: "1", timesOfDay: ["08:00"] })]); + + it("binds a take 3 days after the anchor to the slot as late, not ad-hoc", () => { + // Thursday 08:00 — inside the 4-day overdue tail, but 3 days past the + // anchor: a ±1-day mint range never surfaces the Monday band. + const takenAt = at(new Date("2026-06-11T12:00:00Z"), 8, 0); + const out = attributeTakenToSlot({ + medication: weekly, + userTz: TZ, + takenAt, + }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 8, 0).getTime()); + expect(out.status).toBe("late"); + }); + + it("treats a take past the overdue tail as ad-hoc (bi-weekly, +6 days)", () => { + // Bi-weekly Monday anchored at startsOn = Mon 2026-06-08; the next + // anchor (Jun 22) is two weeks out, so day +6 sits in the dead zone + // past the tail (Sat Jun 13 08:00) and before the next on-time band. + const biWeekly = med( + [schedule({ daysOfWeek: "i2;1", timesOfDay: ["08:00"] })], + { startsOn: new Date("2026-06-08T00:00:00Z") }, + ); + const takenAt = at(new Date("2026-06-14T12:00:00Z"), 8, 0); + const out = attributeTakenToSlot({ + medication: biWeekly, + userTz: TZ, + takenAt, + }); + expect(out.slotInstant).toBeNull(); + expect(out.status).toBeNull(); + expect(out.hasExpectedSlots).toBe(true); + }); + + it("binds a rolling take 4 days after the projected next-due as late", () => { + // 7-day rolling, last shot Mon Jun 1 08:00 → next due Mon Jun 8 08:00. + // Take Friday Jun 12 08:00: 4 days past the anchor (inside the tail, + // and past the half-cycle gate that emits the overdue forward slot). + const rolling = med([ + schedule({ rollingIntervalDays: 7, timesOfDay: ["08:00"] }), + ]); + const lastShot = at(new Date("2026-06-01T12:00:00Z"), 8, 0); + const takenAt = at(new Date("2026-06-12T12:00:00Z"), 8, 0); + const out = attributeTakenToSlot({ + medication: rolling, + userTz: TZ, + takenAt, + lastIntakeAt: lastShot, + intakeInstants: [lastShot], + }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 8, 0).getTime()); + expect(out.status).toBe("late"); + }); + + it("binds a 3-days-late weekly take across the spring DST transition", () => { + // Friday 2026-03-27 08:00 CET anchor; the take lands Monday 2026-03-30 + // 08:00 CEST — 3 days later across the Mar 29 spring-forward shift. + const friday = med([schedule({ daysOfWeek: "5", timesOfDay: ["08:00"] })]); + const anchorDay = new Date("2026-03-27T12:00:00Z"); + const takenAt = at(new Date("2026-03-30T12:00:00Z"), 8, 0); + const out = attributeTakenToSlot({ + medication: friday, + userTz: TZ, + takenAt, + }); + expect(out.slotInstant?.getTime()).toBe(at(anchorDay, 8, 0).getTime()); + expect(out.status).toBe("late"); + }); +}); + describe("attributeTakenToSlot — DST correctness", () => { it("snaps a winter (CET) on-time take to its slot", () => { const winterDay = new Date("2026-01-15T12:00:00Z"); // CET diff --git a/src/lib/medications/scheduling/attribute-intake.ts b/src/lib/medications/scheduling/attribute-intake.ts index 9d329562e..11c849230 100644 --- a/src/lib/medications/scheduling/attribute-intake.ts +++ b/src/lib/medications/scheduling/attribute-intake.ts @@ -29,10 +29,11 @@ import { type SlotBand, } from "@/lib/medications/scheduling/attribution"; import { - buildBandsForSchedules, + buildBandsForMedication, type BandMinterMedication, type DoseWindowConfig, } from "@/lib/medications/scheduling/band-minter"; +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; import { buildCanonicalSchedule, buildRecurrenceContext, @@ -116,31 +117,53 @@ export function buildMedicationDayBands(input: { }); const canonicalSchedules = schedules.map(buildCanonicalSchedule); - // Mint over the take's local day padded a day each side so a slot near the - // local-midnight boundary (and any DST shift) is still captured — mirrors - // `resolve-slot-instant.ts`. A weekly/rolling band reaches further; its - // minter widens the probe internally, so the padded day window is enough to - // surface the anchor that the take could pair to. - const from = new Date(input.around.getTime() - ONE_DAY_MS); - const to = new Date(input.around.getTime() + ONE_DAY_MS); - - const groups = buildBandsForSchedules({ - medication: bandMinterMedication, - schedules: canonicalSchedules, - ctx, - userTz: input.userTz, - range: { from, to }, - now: input.now ?? input.around, - windowConfig: input.windowConfig, - intakeInstants: input.intakeInstants, - }); + // Minute-scale cadences: mint over the take's local day padded a day each + // side so a slot near the local-midnight boundary (and any DST shift) is + // still captured — mirrors `resolve-slot-instant.ts`. + // + // Day-scale cadences (weekly / rolling) need a wider mint range: a band's + // late tail reaches `weeklyOnTimeDays + weeklyOverdueDays` days past its + // anchor, so a take inside that tail sits several days AFTER the anchor — + // a ±1-day range would never mint the anchor's band and the take would be + // misfiled as ad-hoc. The minter widens its probe internally only for + // family CLASSIFICATION, not for minting, so the range itself must cover + // the band's full reach (derived from the configured / default windows, + // plus a day of midnight/DST padding). Each schedule is minted narrow + // first; a day-scale family is then re-minted over the wide range. + const minuteScaleRange = { + from: new Date(input.around.getTime() - ONE_DAY_MS), + to: new Date(input.around.getTime() + ONE_DAY_MS), + }; + const dayScaleReachDays = + (input.windowConfig?.weeklyOnTimeDays ?? + DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays) + + (input.windowConfig?.weeklyOverdueDays ?? + DOSE_WINDOW_DEFAULTS.weeklyOverdueDays) + + 1; + const dayScaleRange = { + from: new Date(input.around.getTime() - dayScaleReachDays * ONE_DAY_MS), + to: new Date(input.around.getTime() + dayScaleReachDays * ONE_DAY_MS), + }; const bands: SlotBand[] = []; let hasExpectedSlots = false; - for (const g of groups) { - if (g.hasExpectedSlots) { + for (const schedule of canonicalSchedules) { + const common = { + medication: bandMinterMedication, + schedule, + ctx, + userTz: input.userTz, + now: input.now ?? input.around, + windowConfig: input.windowConfig, + intakeInstants: input.intakeInstants, + }; + let result = buildBandsForMedication({ ...common, range: minuteScaleRange }); + if (result.family === "weekly") { + result = buildBandsForMedication({ ...common, range: dayScaleRange }); + } + if (result.hasExpectedSlots) { hasExpectedSlots = true; - bands.push(...g.bands); + bands.push(...result.bands); } } return { bands, hasExpectedSlots }; From 09dbb1131df592b929f2cbf4d3f1da138619d3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:17:48 +0200 Subject: [PATCH 05/11] fix(medications): guard intake edits against implausible timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An intake edit accepted any takenAt value, so a date typo could park a dose a month away from its slot and poison the today feed, the rollup and the rolling next-due anchor at once. The update schema now rejects future timestamps (with clock-skew tolerance) and values older than five years, the route rejects edits before the medication's start date, and the edit dialog caps the picker at now, shows the scheduled slot and warns when the new time sits more than 48 hours from it. The dialog's note field is gone — it was never persisted and silently discarded input. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../intake/[eventId]/__tests__/route.test.ts | 100 ++++++++++++++++++ .../[id]/intake/[eventId]/route.ts | 30 ++++++ .../intake-edit-dialog-seed.test.tsx | 64 +++++++++++ .../medications/dose-history-ledger.tsx | 3 + .../medications/intake-edit-dialog.tsx | 87 ++++++++++----- .../medications/intake-history-editable.tsx | 1 + .../__tests__/medication-intake-event.test.ts | 76 +++++++++++++ src/lib/validations/medication.ts | 21 +++- 14 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 src/lib/validations/__tests__/medication-intake-event.test.ts diff --git a/messages/de.json b/messages/de.json index 8ab6d3dbb..430d15ebc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Einnahme bearbeiten", "takenAtLabel": "Eingenommen um", + "scheduledForHint": "Geplant: {dateTime}", + "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.", "skippedLabel": "Übersprungen", - "noteLabel": "Notiz", "save": "Speichern", "cancel": "Abbrechen", "savedToast": "Einnahme aktualisiert", diff --git a/messages/en.json b/messages/en.json index 58d054157..aac9080a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edit intake", "takenAtLabel": "Taken at", + "scheduledForHint": "Scheduled: {dateTime}", + "farFromScheduledWarning": "This is far from the scheduled time — check the date.", "skippedLabel": "Skipped", - "noteLabel": "Note", "save": "Save", "cancel": "Cancel", "savedToast": "Intake updated", diff --git a/messages/es.json b/messages/es.json index a07cce72b..b37cd76c0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Editar toma", "takenAtLabel": "Tomada a las", + "scheduledForHint": "Programada: {dateTime}", + "farFromScheduledWarning": "Está muy lejos de la hora programada; comprueba la fecha.", "skippedLabel": "Omitida", - "noteLabel": "Nota", "save": "Guardar", "cancel": "Cancelar", "savedToast": "Toma actualizada", diff --git a/messages/fr.json b/messages/fr.json index 75f0bb202..3399e0725 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifier la prise", "takenAtLabel": "Prise à", + "scheduledForHint": "Prévue : {dateTime}", + "farFromScheduledWarning": "Très éloignée de l’heure prévue — vérifiez la date.", "skippedLabel": "Sautée", - "noteLabel": "Note", "save": "Enregistrer", "cancel": "Annuler", "savedToast": "Prise mise à jour", diff --git a/messages/it.json b/messages/it.json index 8d6623f95..b56733170 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifica assunzione", "takenAtLabel": "Assunta alle", + "scheduledForHint": "Programmata: {dateTime}", + "farFromScheduledWarning": "Molto distante dall’orario programmato — controlla la data.", "skippedLabel": "Saltata", - "noteLabel": "Nota", "save": "Salva", "cancel": "Annulla", "savedToast": "Assunzione aggiornata", diff --git a/messages/pl.json b/messages/pl.json index c3266cdd8..71c5b8d23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edytuj przyjęcie", "takenAtLabel": "Przyjęto o", + "scheduledForHint": "Zaplanowano: {dateTime}", + "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu — sprawdź datę.", "skippedLabel": "Pominięto", - "noteLabel": "Notatka", "save": "Zapisz", "cancel": "Anuluj", "savedToast": "Przyjęcie zaktualizowane", diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 8337a3b27..cde47a0cd 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -358,6 +358,106 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime()); }); + it("422s an edited takenAt before the medication's start date (P0-4)", async () => { + // Use fixed wall-clock so the schema's "not in the future / within 5 + // years" bounds hold regardless of when the suite runs. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-05-15T10:00:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("start date"); + // The guard fires before any write. + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not false-reject a takenAt in the early hours of the start day (tz day-key)", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + // startsOn 2026-06-01; the edit is 00:30 local Berlin on the start day, + // which is 2026-05-31T22:30Z — a naive UTC compare would reject it. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: new Date("2026-05-31T22:30:00Z"), + takenAt: new Date("2026-05-31T22:30:00Z"), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-06-01T00:30:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + } finally { + vi.useRealTimers(); + } + }); + it("422s a forceSlotInstant that is not a real slot", async () => { vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ id: "e1", diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 5a8a76f19..0e1916ada 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -14,6 +14,7 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, resolveForcedSlotForWrite, @@ -76,6 +77,35 @@ export const PUT = apiHandler( const data = parsed.data; + // v1.15.19 — start-date guard on an edited `takenAt` (audit P0-4). The + // schema bounds the instant to a plausible absolute range; this check + // adds the per-medication floor: a take cannot predate the day the + // medication starts (calendar-day comparison in the user's timezone, so + // a take in the early hours of the start day never false-rejects). + if (data.takenAt instanceof Date) { + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { startsOn: true }, + }); + if (medication?.startsOn) { + const takenDay = dayKeyForUserTz(data.takenAt, user.timezone); + const startsOnDay = medication.startsOn.toISOString().slice(0, 10); + if (takenDay < startsOnDay) { + annotate({ + action: { + name: "medications.intake.event.update.before-start-date", + }, + meta: { medication_id: id, event_id: eventId }, + }); + return apiError( + "takenAt is before the medication's start date", + 422, + { errorCode: "medications.intake.taken_at.before_start" }, + ); + } + } + } + // The post-edit state of the row (current values where the body omits a // field) — drives the re-attribution decision. const nextTakenAt = diff --git a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx index 3111fb0ed..51cebe654 100644 --- a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx +++ b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx @@ -17,6 +17,9 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@/lib/i18n/context", () => ({ useTranslations: () => ({ t: (key: string) => key }), + useFormatters: () => ({ + dateTime: (value: string | Date) => new Date(value).toISOString(), + }), })); vi.mock("@tanstack/react-query", () => ({ @@ -77,3 +80,64 @@ describe("IntakeEditDialog seed (G-1 §9 Q1)", () => { expect(html).toBe(""); }); }); + +describe("IntakeEditDialog guardrails (P0-4)", () => { + it("caps the datetime-local picker at now via the max attribute", () => { + const taken = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + // `YYYY-MM-DDTHH:mm` — exact value depends on render time, so pin the + // attribute presence + shape rather than the instant. + expect(html).toMatch(/max="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"/); + }); + + it("renders the scheduled-slot reference when the event carries one", () => { + const slot = new Date(2026, 2, 4, 8, 0).toISOString(); + const taken = new Date(2026, 2, 4, 8, 10).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).toContain("medications.detail.intake.edit.scheduledForHint"); + // An on-time take never triggers the far-from-slot warning. + expect(html).not.toContain( + "medications.detail.intake.edit.farFromScheduledWarning", + ); + }); + + it("shows the non-blocking far-from-slot hint when takenAt sits >48h from the slot", () => { + const slot = new Date(2026, 2, 4, 8, 0).toISOString(); + // A month-off typo — exactly the P0-4 production case. + const taken = new Date(2026, 1, 4, 8, 0).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).toContain( + "medications.detail.intake.edit.farFromScheduledWarning", + ); + }); + + it("no longer renders the note field the API never persisted (LOW-10)", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).not.toContain("intake-edit-note"); + expect(html).not.toContain("medications.detail.intake.edit.noteLabel"); + }); +}); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index d13ddd53e..138ebe900 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -136,6 +136,7 @@ export function DoseHistoryLedger({ id: string; takenAt: string | null; skipped: boolean; + scheduledFor: string | null; } | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [marking, setMarking] = useState(null); @@ -384,6 +385,7 @@ function LedgerRowItem({ id: string; takenAt: string | null; skipped: boolean; + scheduledFor: string | null; }) => void; onDelete: (id: string) => void; }) { @@ -499,6 +501,7 @@ function LedgerRowItem({ id: intake.id as string, takenAt: intake.takenAt, skipped: intake.skipped, + scheduledFor: intake.scheduledFor, }) } className="min-h-9" diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 45e513221..ee0e389c9 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -6,11 +6,19 @@ * * Restores the row-level edit affordance the v1.5.4 flat-form * retirement displaced (I-1 §15). The dialog opens from the - * intake-history kebab; fields are `takenAt` (datetime-local), - * `skipped` (boolean), `note` (string?). On save it PATCHes + * intake-history kebab; fields are `takenAt` (datetime-local) and + * `skipped` (boolean). On save it PATCHes * `/api/medications/{id}/intake/{eventId}` and fires the * medication cache bundle so the inline compliance tile + dashboard * chart converge in the same tick. + * + * v1.15.19 (audit P0-4) — date-typo guardrails on the edit path: + * the picker is capped at "now" (mirrors the add dialog), the row's + * scheduled slot renders under the input so the user sees what they + * are editing against, and a non-blocking hint appears when the + * picked time sits more than 48h from that slot. The former free-text + * note field is gone — the API schema stripped it and nothing ever + * persisted it (audit LOW-10), so the dialog no longer pretends. */ import { useState } from "react"; @@ -29,8 +37,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; export interface IntakeEditDialogProps { @@ -40,20 +47,28 @@ export interface IntakeEditDialogProps { id: string; takenAt: string | null; skipped: boolean; - note?: string | null; + /** The row's slot anchor, ISO — renders as the "Geplant" reference + * and drives the far-from-slot hint. */ + scheduledFor?: string | null; } | null; onClose: () => void; } +/** Past this gap between the picked time and the scheduled slot the + * dialog surfaces the "check the date" hint — generous enough that a + * genuinely late dose never nags, tight enough that a month-off typo + * cannot pass silently. */ +const FAR_FROM_SLOT_MS = 48 * 60 * 60 * 1000; + /** - * Convert an ISO timestamp into the `` + * Convert an ISO timestamp (or Date) into the `` * shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs. * The string is rendered in the user's local time so the picker * matches the dose timestamp the user remembers. */ -function toDateTimeLocal(iso: string | null): string { - if (!iso) return ""; - const d = new Date(iso); +function toDateTimeLocal(value: string | Date | null): string { + if (!value) return ""; + const d = value instanceof Date ? value : new Date(value); if (Number.isNaN(d.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( @@ -61,6 +76,20 @@ function toDateTimeLocal(iso: string | null): string { )}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +/** True when the picked local datetime sits more than 48h from the + * row's scheduled slot. Unparseable or absent values never warn. */ +export function isFarFromSlot( + takenAtLocal: string, + scheduledFor: string | null | undefined, +): boolean { + if (!takenAtLocal || !scheduledFor) return false; + const taken = new Date(takenAtLocal); + const slot = new Date(scheduledFor); + if (Number.isNaN(taken.getTime()) || Number.isNaN(slot.getTime())) + return false; + return Math.abs(taken.getTime() - slot.getTime()) > FAR_FROM_SLOT_MS; +} + export function IntakeEditDialog(props: IntakeEditDialogProps) { // Re-mount the body whenever the event id flips so the form state // initialises from the row the user clicked. The wrapper is also @@ -75,16 +104,19 @@ function IntakeEditDialogBody({ onClose, }: IntakeEditDialogProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(event?.takenAt ?? null), ); const [skipped, setSkipped] = useState(() => event?.skipped ?? false); - const [note, setNote] = useState(() => event?.note ?? ""); const [busy, setBusy] = useState(false); if (!event) return null; + const scheduledFor = event.scheduledFor ?? null; + const showFarHint = !skipped && isFarFromSlot(takenAt, scheduledFor); + async function save() { if (!event || busy) return; setBusy(true); @@ -100,9 +132,6 @@ function IntakeEditDialogBody({ } else { body.takenAt = null; } - if (note.trim().length > 0) { - body.note = note; - } const res = await fetch( `/api/medications/${medicationId}/intake/${event.id}`, { @@ -142,9 +171,30 @@ function IntakeEditDialogBody({ id="intake-edit-taken-at" type="datetime-local" value={takenAt} + max={toDateTimeLocal(new Date())} onChange={(e) => setTakenAt(e.target.value)} disabled={skipped} /> + {scheduledFor && ( +

+ {t("medications.detail.intake.edit.scheduledForHint", { + dateTime: fmt.dateTime(scheduledFor), + })} +

+ )} + {/* Non-blocking: a 48h+ gap to the slot is almost always a + date typo, but a deliberate far edit stays saveable. */} + {showFarHint && ( +

+ {t("medications.detail.intake.edit.farFromScheduledWarning")} +

+ )} -
- -