From a5650d0617c36b449e818dcef644546d72bbce9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 17:33:56 +0200 Subject: [PATCH] fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every standalone intake anchors its slot on the take time since v1.15.19, so guarding the nightly dedup on that shape excluded ordinary drift duplicates from collapsing — the original point of the sweep. Releasing a pinned dose now persists the user's choice on the row, the dedup and the repair script key their exclusion on that flag, the read model reports a released row as deliberately off-schedule instead of re-snapping it within the anchor epsilon, and the attributed badge only shows on rows actually bound to a slot. A few fixture names and stray comments also drop personal references. --- prisma/schema.prisma | 21 +-- scripts/repair-intake-anomalies.ts | 14 +- src/app/api/insights/generate/route.ts | 2 +- .../intake/[eventId]/__tests__/route.test.ts | 44 ++++++ .../[id]/intake/[eventId]/route.ts | 16 ++- .../api/user/profile/__tests__/route.test.ts | 10 +- .../medications/dose-history-ledger.tsx | 11 +- .../__tests__/intake-slot-dedup-pin.test.ts | 126 ++++++++++++------ src/lib/medications/intake-slot-dedup.ts | 51 ++++--- .../scheduling/__tests__/dose-history.test.ts | 35 ++++- .../medications/scheduling/dose-history.ts | 30 ++++- 11 files changed, 267 insertions(+), 93 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47fe79429..9cbe6696b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1441,9 +1441,11 @@ enum IntakeSource { } /// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks -/// a deliberate "diesem Slot zuordnen" decision on an off-window take; the -/// ledger then binds the row by its `scheduledFor` anchor and renders it -/// as taken-late + "zugeordnet" (no compliance flattering, unpinnable). +/// an attribution the user fixed by hand (widened in v1.16.0): either a +/// deliberate "diesem Slot zuordnen" pin onto a named slot, or its released +/// ("Zuordnung lösen") counterpart that stays a standalone ad-hoc take +/// (`scheduledFor === takenAt`). The nightly slot dedup never folds a +/// USER_PIN row into a snap cluster, so neither decision is reverted. enum IntakeAttributionSource { AUTO USER_PIN @@ -1505,11 +1507,14 @@ model MedicationIntakeEvent { /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. /// `AUTO` (default) = the write path attributed the take by window-band /// membership (or the row is a skip / pending mint anchored on its slot). - /// `USER_PIN` = the user deliberately pinned an off-window take onto a - /// named slot ("diesem Slot zuordnen"). The read ledger binds USER_PIN - /// rows by their `scheduledFor` anchor (like skips) instead of re-running - /// takenAt-band attribution, so a pinned take outside the late tail stays - /// on its slot — visibly marked, counted late, never on-time-washed. + /// `USER_PIN` = the user fixed the attribution by hand (v1.16.0): a + /// deliberate pin onto a named slot ("diesem Slot zuordnen") OR the + /// released ("Zuordnung lösen") row that stays deliberately ad-hoc + /// (`scheduledFor === takenAt`). The read ledger binds slot-anchored + /// USER_PIN rows by their `scheduledFor` anchor (like skips) instead of + /// re-running takenAt-band attribution — visibly marked, counted late, + /// never on-time-washed — and surfaces released rows as ad-hoc; the + /// nightly dedup leaves both standalone. attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") source IntakeSource @default(WEB) idempotencyKey String? @unique @map("idempotency_key") diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 2e564b00d..f244c10ea 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -113,7 +113,11 @@ interface IntakeRow { skipped: boolean; source: string; createdAt: Date; - /** v1.16.0 — slot-binding provenance; USER_PIN = deliberate user pin. */ + /** + * v1.16.0 — slot-binding provenance; USER_PIN = the user fixed the + * attribution by hand (pin onto a slot OR released as deliberately + * ad-hoc with `scheduledFor === takenAt`). + */ attributionSource: string; } @@ -121,8 +125,12 @@ interface IntakeRow { * Rank a row for the slot-winner pick: pinned take (3) > taken (2) > * skipped (1) > pending (0). `takenAt` set and `skipped` together still * counts as taken — the recorded dose dominates. The USER_PIN rung - * (v1.16.0) mirrors the dedup worker: a deliberate "diesem Slot - * zuordnen" decision is the dose of record for its slot. + * (v1.16.0) mirrors the dedup worker's rank + * (`src/lib/medications/intake-slot-dedup.ts`). The worker additionally + * excludes USER_PIN rows from its SNAP clusters (the snap could move a + * user-fixed anchor); this script needs no such exclusion — its groups + * are exact-instant, so a USER_PIN row in a group already sits ON its + * fixed anchor and the rank keeps it as the dose of record there. */ function rowRank(row: IntakeRow): number { if (row.takenAt !== null) { diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 612f92dff..9bec76511 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -430,7 +430,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the // serialised payload. If the raw (bucketed) shape ever blows past // it (regression watch — the v1.4.35 rawMeasurements shape hit - // 25.9 MB on Marc's account), the helper throws + // 25.9 MB on the largest tenant), the helper throws // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated // shape rather than 500-ing so the user still gets an insight; the // annotate event surfaces the regression to ops via the Logflare 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 56381227c..5a2e2751b 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,50 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime()); }); + it("unpin (forceSlotInstant: null) keeps USER_PIN provenance on the released ad-hoc row (v1.16.0)", async () => { + // The row is pinned onto the 07:00 anchor; its real takenAt (12:00) is + // outside every band. Releasing the binding re-attributes by band → + // miss → the row re-anchors on its own takenAt — and the release is + // itself a user-fixed decision, so the row must keep USER_PIN (the + // nightly dedup keys its standalone guarantee on that marker). + vi.mocked(prisma.medicationIntakeEvent.findFirst) + // PUT lookup of the edited event + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(12, 0), + skipped: false, + } as never) + // lifecycle liveIntake probe + .mockResolvedValue(null as never); + // applyCanonicalSlotWrite: no existing row at the 12:00 instant → create. + 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: at(12, 0), + takenAt: at(12, 0), + skipped: false, + } as never); + + const res = await PUT(putReq({ forceSlotInstant: null }), ROUTE_CTX); + expect(res.status).toBe(200); + + const created = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0]?.[0]?.data; + // Released row: anchored on its own takenAt, provenance stays USER_PIN. + expect((created?.scheduledFor as Date).getTime()).toBe(at(12, 0).getTime()); + expect(created?.attributionSource).toBe("USER_PIN"); + }); + 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. diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 6c94b66b4..b8896c325 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -131,7 +131,12 @@ export const PUT = apiHandler( // anchor instead of degrading the off-window take back to ad-hoc; // 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20, // "Zuordnung lösen"): re-attribute the take by band on its takenAt - // (ad-hoc when no band matches) and reset the provenance to AUTO; + // (ad-hoc when no band matches). v1.16.0 — the release is itself a + // deliberate binding decision, so the row KEEPS `USER_PIN` + // provenance ("attribution user-fixed": onto a slot OR deliberately + // ad-hoc). The persisted marker is what keeps the nightly slot + // dedup from snapping the released row back into the cluster the + // user just detached it from; // 4. else a taken edit attributes by band (slot anchor on match, the // take's own time on a miss → ad-hoc), provenance AUTO; // 5. a skip / pending edit keeps the existing `scheduledFor` anchor. @@ -198,10 +203,15 @@ export const PUT = apiHandler( }); // band.at on match; the take's own time on a miss (ad-hoc). resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt; - attributionSource = "AUTO"; + // v1.16.0 — an explicit unpin stays user-fixed (USER_PIN) even on + // the released row; a plain taken/skipped edit re-attributes AUTO. + attributionSource = + data.forceSlotInstant === null ? "USER_PIN" : "AUTO"; } else if (data.forceSlotInstant === null) { // Unpin on a row that is not a confirmed take (skip / pending): - // nothing to re-attribute, but the provenance still resets. + // nothing to re-attribute, but the provenance still resets — a + // skip / pending row anchors on its slot by construction, so there + // is no user-fixed ad-hoc decision to persist. attributionSource = "AUTO"; } // skip / pending edits leave `scheduledFor` on the existing anchor. diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 26607d210..e57697711 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -57,7 +57,7 @@ describe("GET /api/user/profile", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "marc", - displayName: "Marc B.", + displayName: "Alex T.", email: "marc@example.com", dateOfBirth: new Date("1985-03-12T00:00:00.000Z"), gender: "MALE", @@ -77,7 +77,7 @@ describe("GET /api/user/profile", () => { }; }; expect(body.data.username).toBe("marc"); - expect(body.data.displayName).toBe("Marc B."); + expect(body.data.displayName).toBe("Alex T."); expect(body.data.heightCm).toBe(180); expect(body.data.locale).toBe("de"); }); @@ -137,7 +137,7 @@ describe("PATCH /api/user/profile", () => { vi.mocked(prisma.user.update).mockResolvedValue({ id: "user-1", username: "marc", - displayName: "Marc B.", + displayName: "Alex T.", email: null, role: "USER", heightCm: null, @@ -147,13 +147,13 @@ describe("PATCH /api/user/profile", () => { locale: "de", } as never); - const res = await PATCH(req({ displayName: "Marc B.", locale: "de" })); + const res = await PATCH(req({ displayName: "Alex T.", locale: "de" })); expect(res.status).toBe(200); expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "user-1" }, data: expect.objectContaining({ - displayName: "Marc B.", + displayName: "Alex T.", locale: "de", }), }), diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index d7bbf9d03..5d09312f8 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -29,7 +29,10 @@ * kebab "Slot … zuordnen" action that pins the take onto it after a * confirm that names the consequence ("zählt als verspätet"). A pinned * slot row carries a quiet "zugeordnet" badge and the inverse - * "Zuordnung lösen" kebab action (`forceSlotInstant: null`). + * "Zuordnung lösen" kebab action (`forceSlotInstant: null`). v1.16.0: + * the released row keeps USER_PIN provenance server-side (the dedup + * leaves it standalone) and reads as a plain ad-hoc row here — no + * badge, since it is no longer bound to a slot. * * A compact took / skipped history chart sits at the top — the ledger already * carries the counts. The card body never tints by status (removed earlier); @@ -634,7 +637,11 @@ function LedgerRowItem({ {t("medications.detail.verlauf.adHocTag")} )} - {row.pinned && ( + {/* v1.16.0 — the "zugeordnet" badge only when the row is actually + bound to a slot. A released ("Zuordnung lösen") USER_PIN take + surfaces as an ad-hoc row that also carries `pinned`; badging + it "zugeordnet" would claim the binding the user just released. */} + {row.kind === "slot" && row.pinned && ( { }); describe("dedupeUserIntakeSlots — pin-awareness (v1.16.0)", () => { - it("never re-merges a deliberate ad-hoc take (scheduledFor === takenAt) into a slot cluster", async () => { - // 07:00 CEST = 05:00Z. The pending REMINDER row sits on the anchor; - // the unpinned take sits 40 min later — inside the legacy snap - // tolerance, outside the user's deliberate ad-hoc decision. + it("re-pin scenario: a released USER_PIN row stays standalone next to the re-minted pending", async () => { + // 07:00 CEST = 05:00Z. The user released a binding: the take keeps + // USER_PIN with `scheduledFor === takenAt` 40 min past the anchor — + // inside the legacy snap tolerance. The projector then re-minted a + // pending row on the freed 05:00Z slot. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { - id: "r-07-rem", + id: "r-07-pending", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T06:00:00Z"), + attributionSource: "AUTO", + }, + { + id: "r-07-released", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:40:00.000Z"), + takenAt: new Date("2026-06-15T05:40:00.000Z"), + skipped: false, + syncVersion: 3, + createdAt: new Date("2026-06-15T05:40:00Z"), + attributionSource: "USER_PIN", + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + // The released row never joins the 05:00 cluster → the slot holds one + // row (the fresh pending) → nothing collapses; the pending survives to + // auto-miss / be taken normally. + expect(summary.slotsCollapsed).toBe(0); + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("still collapses an AUTO drift duplicate despite its scheduledFor === takenAt anchor shape", async () => { + // The v1.15.19 standalone-insert anchor shape: the iOS take landed + // +60 s beside the server-minted pending row, provenance AUTO. This is + // exactly the duplicate the dedup exists for — the persisted-provenance + // exclusion must NOT shield it the way the old shape heuristic did. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-07-pending", medicationId: "med-1", scheduledFor: new Date("2026-06-15T05:00:00.000Z"), takenAt: null, @@ -103,31 +153,31 @@ describe("dedupeUserIntakeSlots — pin-awareness (v1.16.0)", () => { attributionSource: "AUTO", }, { - id: "r-07-adhoc", + id: "r-07-drift", medicationId: "med-1", - scheduledFor: new Date("2026-06-15T05:40:00.000Z"), - takenAt: new Date("2026-06-15T05:40:00.000Z"), + scheduledFor: new Date("2026-06-15T05:01:00.000Z"), + takenAt: new Date("2026-06-15T05:01:00.000Z"), skipped: false, - syncVersion: 3, - createdAt: new Date("2026-06-15T05:40:00Z"), + syncVersion: 2, + createdAt: new Date("2026-06-15T05:01:00Z"), attributionSource: "AUTO", }, ] as never); const summary = await dedupeUserIntakeSlots("u1"); - // The ad-hoc row never joins the 05:00 cluster → the slot holds one - // row → nothing collapses, nothing is soft-deleted or normalised. - expect(summary.slotsCollapsed).toBe(0); - expect(summary.rowsSoftDeleted).toBe(0); - expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); - expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + // The pair collapses onto the taken row; the pending is the loser. + expect(summary.slotsCollapsed).toBe(1); + expect(summary.rowsSoftDeleted).toBe(1); + const updateManyArg = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.calls[0][0] as { where: { id: { in: string[] } } }; + expect(updateManyArg.where.id.in).toEqual(["r-07-pending"]); }); - it("keeps the USER_PIN row as the slot winner over a higher-syncVersion sibling take", async () => { - // Both rows sit on the 05:00Z anchor (cross-source race shape). The - // sibling AUTO take has the higher syncVersion — without the pin rung - // it would win the tie-break and the user's pin would be tombstoned. + it("leaves a USER_PIN row and its same-anchor AUTO sibling both alive (no collapse)", async () => { + // Cross-source race shape: both rows sit on the 05:00Z anchor. The pin + // never enters the cluster, so the cluster holds one row and nothing + // is soft-deleted — neither dose record is destroyed. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { id: "r-07-pin", @@ -153,10 +203,8 @@ describe("dedupeUserIntakeSlots — pin-awareness (v1.16.0)", () => { const summary = await dedupeUserIntakeSlots("u1"); - expect(summary.slotsCollapsed).toBe(1); - // The AUTO sibling is the loser; the pinned row survives. - const updateManyArg = vi.mocked(prisma.medicationIntakeEvent.updateMany) - .mock.calls[0][0] as { where: { id: { in: string[] } } }; - expect(updateManyArg.where.id.in).toEqual(["r-07-auto"]); + expect(summary.slotsCollapsed).toBe(0); + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index fe4afe0c4..00cad4e89 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -98,34 +98,22 @@ interface DedupIntakeRow { skipped: boolean; syncVersion: number; createdAt: Date; - /** v1.16.0 — slot-binding provenance; USER_PIN = deliberate user pin. */ + /** + * v1.16.0 — slot-binding provenance; USER_PIN = the user fixed the + * attribution by hand (pin onto a slot OR released as deliberately + * ad-hoc). Such a row never joins a snap cluster. + */ attributionSource: string; } -/** - * v1.16.0 — true for a row the band-era write path deliberately recorded - * as a standalone ad-hoc take (`scheduledFor === takenAt`, the unpin / - * outside-every-band signature). The dedup snap must never second-guess - * that decision: the canonical-instant resolver still uses the legacy - * ± half-window tolerance, which is WIDER than the band model, so an - * unpinned ("Zuordnung gelöst") or early off-band take within snap reach - * of a slot would otherwise be re-merged into that slot's cluster — - * silently reverting the user's binding decision (and, when the slot row - * is also a take, soft-deleting one of two real dose records). - */ -function isDeliberateAdHocRow(row: DedupIntakeRow): boolean { - return ( - row.takenAt !== null && - row.scheduledFor.getTime() === row.takenAt.getTime() - ); -} - /** * Winner priority of a row inside a slot: pinned take (3) > taken (2) > * skipped (1) > pending (0). A higher number wins. The USER_PIN rung - * (v1.16.0) keeps a deliberate "diesem Slot zuordnen" decision from - * losing the tie-break against a same-slot sibling row — the pin is the - * dose of record for its slot. + * (v1.16.0) is defensive here — USER_PIN rows never enter a cluster in + * the first place (see the exclusion in `dedupeUserIntakeSlots`) — but it + * keeps this rank the mirror of the repair script's `rowRank` + * (`scripts/repair-intake-anomalies.ts`), where exact-instant groups DO + * contain pins and the pin is the dose of record for its slot. */ function rowPriority(row: DedupIntakeRow): number { if (row.takenAt !== null) { @@ -244,7 +232,7 @@ export async function dedupeUserIntakeSlots( skipped: true, syncVersion: true, createdAt: true, - // v1.16.0 — pin-awareness: a USER_PIN row must win its slot. + // v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster. attributionSource: true, }, orderBy: { scheduledFor: "asc" }, @@ -257,10 +245,19 @@ export async function dedupeUserIntakeSlots( // never collapse together. const slots = new Map(); for (const row of rows) { - // v1.16.0 — a deliberate band-era ad-hoc take never joins a slot - // cluster (see `isDeliberateAdHocRow`). Declining to collapse is - // always safe: the row simply keeps its own anchor. - if (isDeliberateAdHocRow(row)) continue; + // v1.16.0 — a USER_PIN row (attribution fixed by the user: pinned + // onto a slot, or released as deliberately ad-hoc with + // `scheduledFor === takenAt`) never joins a slot cluster. The + // canonical-instant resolver still uses the legacy ± half-window + // tolerance, WIDER than the band model, so snapping would silently + // revert the user's binding decision (and, when the slot row is + // also a take, soft-delete one of two real dose records). Keyed on + // the persisted provenance — NOT on the `scheduledFor === takenAt` + // shape, which since v1.15.19 every AUTO standalone insert shares: + // those legitimate drift duplicates (an iOS row +60 s beside the + // server pending row) MUST stay collapsible. Declining to collapse + // a pin is always safe: the row simply keeps its own anchor. + if (row.attributionSource === "USER_PIN") continue; const canonical = resolveCanonicalSlotInstant({ medication: { id: med.id, diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index c635bcaad..c0a7056ef 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -87,7 +87,7 @@ describe("reconstructDoseHistory", () => { }); it("emits an 11:29 take as an ad-hoc row and leaves 07:00 missed", () => { - // Marc's case: the stored scheduledFor was snapped to 07:00 by the old + // the reported case: the stored scheduledFor was snapped to 07:00 by the old // write path, but the real takenAt (11:29) is outside every band → ad-hoc. const rows = reconstructDoseHistory( bands, @@ -333,6 +333,39 @@ describe("reconstructDoseHistory", () => { expect(adhoc).toHaveLength(1); expect(adhoc[0].at.toISOString()).toBe(at(15, 30).toISOString()); }); + + // v1.16.0 — a RELEASED pin ("Zuordnung lösen") persists USER_PIN with + // `scheduledFor === takenAt`: deliberately ad-hoc, never anchor-bound. + it("surfaces a released pin (scheduledFor === takenAt) as a pinned ad-hoc row, never taken_late", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(9, 0), scheduledFor: at(9, 0), pinned: true })], + nowEvening, + ); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc).toHaveLength(1); + expect(adhoc[0].status).toBe("ad_hoc"); + expect(adhoc[0].pinned).toBe(true); + // The 07:00 slot stays unserved (missed by evening) — the released + // take must not be pulled back onto it. + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("a released pin within anchor epsilon of a slot still reads ad-hoc (no re-binding)", () => { + // 07:00:30 sits 30 s from the 07:00 anchor — inside ANCHOR_EPSILON_MS. + // Without the release guard the pinned path would claim the slot as + // taken_on_time, silently reverting the user's release. + const t = new Date(at(7, 0).getTime() + 30_000); + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: t, scheduledFor: t, pinned: true })], + nowEvening, + ); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc).toHaveLength(1); + expect(adhoc[0].pinned).toBe(true); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); }); // v1.15.20 — the due-context an ad-hoc take carries. With the test diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 2a8cac8fb..2f0d500e0 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -24,6 +24,11 @@ * never degrades back to ad-hoc. Status is `taken_late` unless the * takenAt happens to sit inside the slot's on-time band — a pin can * never flatter the timing; + * - a RELEASED pin (v1.16.0 — "Zuordnung lösen" persists `USER_PIN` with + * `scheduledFor === takenAt`) is a deliberately ad-hoc take: it never + * anchor-binds (not even when its instant sits within epsilon of a slot + * anchor) and surfaces as an ad-hoc row carrying `pinned`, never as + * `taken_late`; * - each slot is claimed by at most one intake (first/best wins); extra * intakes near a filled slot fall through to ad-hoc. * @@ -46,9 +51,11 @@ export interface HistoryIntake { /** Cron-marked forgotten dose (counts as missed, not skipped). */ autoMissed?: boolean; /** - * v1.15.20 — `attributionSource === "USER_PIN"`: the user deliberately - * pinned this take onto its `scheduledFor` slot. Binds by anchor, not by - * takenAt band. Optional so legacy callers / fixtures default to AUTO. + * v1.15.20 — `attributionSource === "USER_PIN"`: the user fixed this + * row's attribution by hand. Pinned onto a slot (`scheduledFor` is the + * slot anchor): binds by anchor, not by takenAt band. Released + * (v1.16.0, `scheduledFor === takenAt`): deliberately ad-hoc, never + * anchor-binds. Optional so legacy callers / fixtures default to AUTO. */ pinned?: boolean; } @@ -93,7 +100,10 @@ export interface DoseHistoryRow { /** * v1.15.20 — true when this slot row is served by a USER_PIN intake (a * deliberate "diesem Slot zuordnen" decision). The UI badges it - * "zugeordnet" and offers "Zuordnung lösen". + * "zugeordnet" and offers "Zuordnung lösen". v1.16.0 — also true on an + * `ad_hoc` row whose intake is USER_PIN (a released / deliberately + * ad-hoc take); the UI shows NO badge there (the row is not slot-bound), + * it only marks the binding as user-fixed. */ pinned?: boolean; /** @@ -168,6 +178,15 @@ export function reconstructDoseHistory( // slot's own on-time band anyway. A pin whose slot is gone (schedule // changed) or already claimed falls through to ad-hoc so nothing vanishes. for (const i of pinnedTaken) { + // v1.16.0 — a released pin ("Zuordnung lösen") persists USER_PIN with + // `scheduledFor === takenAt`: the user fixed the attribution as + // deliberately ad-hoc. Route it straight to ad-hoc — anchor-binding it + // (its instant can sit within epsilon of a slot anchor) would re-attach + // the very binding the user released and mislabel the row taken_late. + if (i.scheduledFor.getTime() === (i.takenAt as Date).getTime()) { + adHoc.push(i); + continue; + } const band = nearestAnchorBand(i.scheduledFor, bands); if (band && !claim.has(band)) { const t = (i.takenAt as Date).getTime(); @@ -236,6 +255,9 @@ export function reconstructDoseHistory( timeOfDay: null, status: "ad_hoc", intake: i, + // v1.16.0 — a USER_PIN intake surfacing ad-hoc (released, or its + // pinned slot vanished / was claimed) keeps the user-fixed marker. + ...(i.pinned && { pinned: true }), ...(nearestSlot && { nearestSlot }), }); }