feat(medications): attribute an off-schedule intake to its slot, visibly and reversibly

An off-schedule row now tells the user when the dose had been due, and
its row menu offers to attribute it to that slot when the slot is still
unserved. A pinned intake is a deliberate decision with a visible badge,
it always counts as taken late — never on time — and the pin can be
released again. The read model binds pinned rows by their slot anchor so
a pin outside the catch-up window survives reloads, and the suggestion
respects the schedule's own windows instead of a hard-coded tolerance.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 15:39:55 +02:00
1 parent d7bf0df10b
commit 0cf9bc9348
21 files changed
+802 -17

No files matched your search

@@ -86,7 +86,14 @@ export const GET = apiHandler(
deletedAt: null,
scheduledFor: { gte: from },
},
select: { scheduledFor: true, takenAt: true, skipped: true },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
// v1.15.20 — pinned takes bind by anchor in the chip tally so the
// chips agree with the % + the history on a "zugeordnet" dose.
attributionSource: true,
},
orderBy: { scheduledFor: "asc" },
});
@@ -458,12 +458,14 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
expect(gte).toBeInstanceOf(Date);
const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000;
expect((gte as Date).getTime()).toBeGreaterThan(minExpected);
// Column-bounded: only the four fields the math consumes.
// Column-bounded: only the five fields the math consumes
// (v1.15.20 adds the slot-binding provenance for pinned takes).
expect(call.select).toEqual({
takenAt: true,
skipped: true,
scheduledFor: true,
autoMissed: true,
attributionSource: true,
});
});
});
@@ -142,6 +142,9 @@ async function buildCompliancePayload(
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
// miss, not a neutral skip.
autoMissed: true,
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
// % agrees with the history view on a "zugeordnet" dose (taken-late).
attributionSource: true,
},
});
@@ -77,6 +77,12 @@ interface SerializedDoseHistoryRow {
at: string;
timeOfDay: string | null;
status: DoseHistoryRow["status"];
/** v1.15.20 — slot served by a deliberate user pin ("zugeordnet"). */
pinned?: boolean;
/** v1.15.20 — due-context for an ad-hoc take: the nearest slot it could
* belong to (preferring an unserved one), so the UI can show "fällig
* gewesen" and offer the pin when `filled` is false. */
nearestSlot?: { at: string; timeOfDay: string; filled: boolean };
intake: {
id: string | null;
scheduledFor: string;
@@ -148,6 +154,8 @@ export const GET = apiHandler(
takenAt: true,
skipped: true,
autoMissed: true,
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
attributionSource: true,
},
});
@@ -157,6 +165,7 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
pinned: e.attributionSource === "USER_PIN",
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -226,6 +235,7 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
pinned: e.pinned,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
@@ -235,6 +245,14 @@ export const GET = apiHandler(
at: row.at.toISOString(),
timeOfDay: row.timeOfDay,
status: row.status,
...(row.pinned && { pinned: true }),
...(row.nearestSlot && {
nearestSlot: {
at: row.nearestSlot.at.toISOString(),
timeOfDay: row.nearestSlot.timeOfDay,
filled: row.nearestSlot.filled,
},
}),
intake: row.intake
? {
id: row.intake.id ?? null,
@@ -124,13 +124,23 @@ export const PUT = apiHandler(
// leaving `scheduledFor` stale (audit HIGH-4). Precedence:
// 1. an explicit `scheduledFor` in the body wins (deliberate override);
// 2. else `forceSlotInstant` pins onto a named real slot (422 if it is
// not a slot of this med);
// 3. else a taken edit attributes by band (slot anchor on match, the
// take's own time on a miss → ad-hoc);
// 4. a skip / pending edit keeps the existing `scheduledFor` anchor.
// not a slot of this med) — v1.15.20: standalone too, no takenAt /
// skipped change required ("Slot zuordnen" from the ledger kebab),
// and the row is stamped USER_PIN so the read ledger binds it by
// 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;
// 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.
let resolvedScheduledFor: Date | undefined = data.scheduledFor;
if (resolvedScheduledFor === undefined && takenOrSkippedChanged) {
if (data.forceSlotInstant !== undefined) {
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (resolvedScheduledFor === undefined) {
if (
data.forceSlotInstant !== undefined &&
data.forceSlotInstant !== null
) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
@@ -149,7 +159,12 @@ export const PUT = apiHandler(
);
}
resolvedScheduledFor = forced;
} else if (nextTakenAt !== null && !nextSkipped) {
attributionSource = "USER_PIN";
} else if (
(data.forceSlotInstant === null || takenOrSkippedChanged) &&
nextTakenAt !== null &&
!nextSkipped
) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
@@ -158,6 +173,11 @@ export const PUT = apiHandler(
});
// band.at on match; the take's own time on a miss (ad-hoc).
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
attributionSource = "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.
attributionSource = "AUTO";
}
// skip / pending edits leave `scheduledFor` on the existing anchor.
}
@@ -175,6 +195,11 @@ export const PUT = apiHandler(
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
// v1.15.20 — binding provenance: pin / unpin / band re-attribution
// stamps it even when the anchor itself did not move (e.g. a pin
// onto the slot the row already sits on, or an unpin whose band
// re-attribution lands on the same anchor).
...(attributionSource !== undefined && { attributionSource }),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
@@ -203,6 +228,7 @@ export const PUT = apiHandler(
isExplicitSkip: nextSkipped,
idempotencyKey: null,
createSource: "WEB",
attributionSource,
});
updated = applied.row;
}
@@ -167,6 +167,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// `forceSlotInstant` pins an off-window take onto a chosen real slot
// ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422.
let canonicalSlot: Date | null = null;
// v1.15.20 — binding provenance for the written row: USER_PIN on the
// forced "diesem Slot zuordnen" path, AUTO when band attribution decided.
// Skips carry no binding decision (undefined → column untouched/default).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
@@ -194,6 +198,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
attributionSource = "USER_PIN";
} else {
// A non-skip write on this route always carries a `takenAt` (defaulted to
// now), so `resolvedTakenAt` is non-null here; the fallback only guards the
@@ -205,6 +210,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
takenAt: resolvedTakenAt ?? incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
// stale USER_PIN when this write converges onto a previously-pinned row.
attributionSource = "AUTO";
}
// Idempotency check (explicit key or server-side dedup window)
@@ -263,6 +271,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
attributionSource,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
@@ -309,6 +318,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
attributionSource,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
@@ -260,6 +260,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// attribute by, so they keep the canonical anchor snap that binds
// them to the projector/worker-minted slot row.
let canonicalSlot: Date | null;
// v1.15.20 — binding provenance: USER_PIN on the forced path, AUTO on
// a band decision. Pending echoes / skips carry no decision
// (undefined → the upsert leaves an existing USER_PIN untouched).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
@@ -276,6 +280,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
results.push({ index: i, status: "skipped", reason });
continue;
}
attributionSource = "USER_PIN";
} else if (isExplicitTaken) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
@@ -284,6 +289,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
takenAt: entry.takenAt as Date,
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
} else {
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
// iOS posts the reminder actions here (source API); without this
@@ -356,6 +362,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
injectionSite: resolvedInjectionSite,
attributionSource,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
@@ -425,6 +432,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
injectionSite: resolvedInjectionSite,
attributionSource,
});
if (applied.noDowngradeNoOp) {
duplicates += 1;