fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 17:33:56 +02:00
1 parent 04765ea8ac
commit a5650d0617
11 files changed
+267 -93

No files matched your search

+13 -8
View File
@@ -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")
+11 -3
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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.
@@ -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.
@@ -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",
}),
}),
@@ -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")}
</Badge>
)}
{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 && (
<Badge
variant="outline"
className="text-[0.65rem]"
@@ -1,22 +1,32 @@
/**
* v1.16.0 — pin-awareness of the intake-slot dedup pass.
*
* Two regressions the v1.15.20 pin/unpin flow could otherwise suffer from
* the nightly dedup cron:
* The exclusion is keyed on the PERSISTED provenance
* (`attributionSource = USER_PIN`), never on the `scheduledFor === takenAt`
* row shape: since v1.15.19 every AUTO standalone insert anchors
* `scheduledFor = takenAt`, so a shape-based guard would also shield the
* legitimate drift duplicates (an iOS row +60 s beside the server pending
* row) the dedup exists to collapse.
*
* 1. A deliberately UNPINNED ("Zuordnung lösen") or outside-every-band
* take is stored as a standalone ad-hoc row with
* `scheduledFor === takenAt`. The dedup's canonical-instant resolver
* still uses the legacy ± half-window snap, which is WIDER than the
* band model — without the guard the cron would re-merge the row into
* the nearest 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).
* Covered here:
*
* 2. A USER_PIN row shares its slot anchor with a sibling row (cross-
* source race). The winner pick must prefer the pinned take — the pin
* IS the dose of record for its slot — instead of letting the
* `syncVersion` tie-break soft-delete the user's deliberate decision.
* 1. The nightly re-pin scenario: the user releases a binding
* ("Zuordnung lösen" → the row keeps USER_PIN with
* `scheduledFor === takenAt`), the projector re-mints a pending row on
* the freed slot, then the dedup cron runs. The released row must stay
* standalone and the pending row must survive untouched (free to
* auto-miss normally) — the snap's legacy ± half-window tolerance is
* WIDER than the band model and would otherwise re-merge the pair,
* silently reverting the user's decision.
*
* 2. The regression guard: an AUTO standalone take with the
* `scheduledFor === takenAt` anchor shape and sub-minute drift IS
* still collapsed into the slot's cluster.
*
* 3. A USER_PIN row sharing its exact slot anchor with an AUTO sibling
* (cross-source race) is left alone: the pin never enters the
* cluster, so nothing is soft-deleted — declining to collapse never
* destroys a dose record.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -87,13 +97,53 @@ beforeEach(() => {
});
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();
});
});
+24 -27
View File
@@ -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<string, { instant: Date; rows: DedupIntakeRow[] }>();
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,
@@ -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
+26 -4
View File
@@ -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 }),
});
}