mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user