mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
04765ea8ac
commit
a5650d0617
11 files changed
+267
-93
No files matched your search
+13
-8
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user