mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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;
|
||||
|
||||
@@ -23,7 +23,13 @@
|
||||
* - a header "+ Eintrag" opens an add dialog scoped to this medication
|
||||
* (incl. backdated / off-schedule); an off-window take surfaces the
|
||||
* "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via
|
||||
* `forceSlotInstant` rather than orphaning it to an ad-hoc row.
|
||||
* `forceSlotInstant` rather than orphaning it to an ad-hoc row;
|
||||
* - v1.15.20: an ad-hoc row shows WHEN the dose would have been due
|
||||
* (`nearestSlot` subline) and — when that slot is still unserved — a
|
||||
* 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`).
|
||||
*
|
||||
* 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);
|
||||
@@ -37,12 +43,14 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Link2,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -79,6 +87,7 @@ import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialo
|
||||
import {
|
||||
applyOptimisticSlotMark,
|
||||
complianceFromLedger,
|
||||
formatSlotDelta,
|
||||
groupLedgerByDay,
|
||||
isSlotActionable,
|
||||
type LedgerPayload,
|
||||
@@ -154,6 +163,12 @@ export function DoseHistoryLedger({
|
||||
} | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [marking, setMarking] = useState<string | null>(null);
|
||||
// v1.15.20 — the pending "Slot zuordnen" confirm: which intake to pin
|
||||
// onto which slot. The confirm names the consequence (counts as late).
|
||||
const [pendingPin, setPendingPin] = useState<{
|
||||
eventId: string;
|
||||
slotAt: string;
|
||||
} | null>(null);
|
||||
|
||||
// Stable window captured once at mount (a lazy state initializer keeps the
|
||||
// impure `Date.now()` out of render) so the query key + the `from`/`to`
|
||||
@@ -339,6 +354,45 @@ export function DoseHistoryLedger({
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.15.20 — pin an ad-hoc take onto a scheduled slot (`forceSlotInstant`)
|
||||
* or release a pinned binding (`forceSlotInstant: null` → the server
|
||||
* re-attributes by band, usually back to ad-hoc). Same PUT contract the
|
||||
* edit dialog uses; the authoritative refetch re-renders the moved row.
|
||||
*/
|
||||
const setAttribution = useCallback(
|
||||
async (eventId: string, slotAtIso: string | null) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${eventId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ forceSlotInstant: slotAtIso }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
t(
|
||||
slotAtIso
|
||||
? "medications.detail.verlauf.pin.pinnedToast"
|
||||
: "medications.detail.verlauf.pin.unpinnedToast",
|
||||
),
|
||||
);
|
||||
await invalidateKeys(queryClient, [
|
||||
...medicationDependentKeys,
|
||||
queryKey,
|
||||
]);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
}
|
||||
},
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
);
|
||||
|
||||
async function confirmRowDelete() {
|
||||
if (!pendingDeleteId) return;
|
||||
const id = pendingDeleteId;
|
||||
@@ -430,6 +484,10 @@ export function DoseHistoryLedger({
|
||||
onToggleSkipped={toggleIntakeSkipped}
|
||||
onEdit={(ev) => setEditingEvent(ev)}
|
||||
onDelete={(id) => setPendingDeleteId(id)}
|
||||
onPin={(eventId, slotAt) =>
|
||||
setPendingPin({ eventId, slotAt })
|
||||
}
|
||||
onUnpin={(eventId) => void setAttribution(eventId, null)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -459,6 +517,18 @@ export function DoseHistoryLedger({
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingPin && (
|
||||
<PinConfirm
|
||||
slotLabel={fmt.time(new Date(pendingPin.slotAt))}
|
||||
onConfirm={() => {
|
||||
const pin = pendingPin;
|
||||
setPendingPin(null);
|
||||
void setAttribution(pin.eventId, pin.slotAt);
|
||||
}}
|
||||
onCancel={() => setPendingPin(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -482,6 +552,8 @@ function LedgerRowItem({
|
||||
onToggleSkipped,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPin,
|
||||
onUnpin,
|
||||
}: {
|
||||
row: LedgerRow;
|
||||
marking: string | null;
|
||||
@@ -499,6 +571,10 @@ function LedgerRowItem({
|
||||
scheduledFor: string | null;
|
||||
}) => void;
|
||||
onDelete: (id: string) => void;
|
||||
/** Open the pin confirm for an ad-hoc take onto the named slot (ISO). */
|
||||
onPin: (eventId: string, slotAt: string) => void;
|
||||
/** Release a pinned binding (server re-attributes by band). */
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -524,6 +600,16 @@ function LedgerRowItem({
|
||||
intake?.takenAt &&
|
||||
(row.status === "taken_on_time" || row.status === "taken_late");
|
||||
|
||||
// v1.15.20 — an ad-hoc take with a due-context shows WHEN the dose would
|
||||
// have been due; the pin action additionally needs the slot unserved and a
|
||||
// real persisted event to re-anchor.
|
||||
const dueContext =
|
||||
row.kind === "ad_hoc" && intake?.takenAt && row.nearestSlot
|
||||
? row.nearestSlot
|
||||
: null;
|
||||
const canPin = Boolean(dueContext && !dueContext.filled && intake?.id);
|
||||
const canUnpin = Boolean(row.kind === "slot" && row.pinned && intake?.id);
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex items-center justify-between gap-2 px-3 py-2"
|
||||
@@ -548,6 +634,15 @@ function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.adHocTag")}
|
||||
</Badge>
|
||||
)}
|
||||
{row.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.65rem]"
|
||||
data-slot="ledger-pinned-badge"
|
||||
>
|
||||
{t("medications.detail.verlauf.pin.pinnedTag")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{showTakeDetail && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -557,6 +652,17 @@ function LedgerRowItem({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{dueContext && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-due-context"
|
||||
>
|
||||
{t("medications.detail.verlauf.pin.dueContext", {
|
||||
time: fmt.time(new Date(dueContext.at)),
|
||||
delta: formatSlotDelta(intake!.takenAt!, dueContext.at),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -624,6 +730,28 @@ function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canPin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onPin(intake!.id as string, dueContext!.at)
|
||||
}
|
||||
data-slot="ledger-pin"
|
||||
>
|
||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.action", {
|
||||
time: fmt.time(new Date(dueContext!.at)),
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onUnpin(intake!.id as string)}
|
||||
data-slot="ledger-unpin"
|
||||
>
|
||||
<Unlink aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.release")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{intake?.id && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
@@ -747,6 +875,54 @@ function LedgerSummaryChart({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — the "Slot zuordnen" confirm. Pinning is a deliberate, visible
|
||||
* decision, so the dialog names the consequence before the write: the dose
|
||||
* will count as taken LATE for that slot (the rate is served, never
|
||||
* flattered) and the row will carry the "zugeordnet" badge until released.
|
||||
*/
|
||||
function PinConfirm({
|
||||
slotLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
slotLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog open onOpenChange={(open) => !open && onCancel()}>
|
||||
<AlertDialogContent data-slot="ledger-pin-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.verlauf.pin.confirmTitle", {
|
||||
time: slotLabel,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.verlauf.pin.confirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("medications.detail.verlauf.pin.confirmCancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
data-slot="ledger-pin-confirm-action"
|
||||
>
|
||||
{t("medications.detail.verlauf.pin.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lightweight single-row delete confirm — the ledger does not need the full
|
||||
* bulk-delete state machine, just a guard. */
|
||||
function DeleteRowConfirm({
|
||||
|
||||
@@ -2582,6 +2582,58 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
expect(tally.rate).toBe(100);
|
||||
});
|
||||
|
||||
// v1.15.20 — a USER_PIN take binds by its slot anchor, so a deliberate
|
||||
// "diesem Slot zuordnen" on an off-window take serves the slot as a LATE
|
||||
// dose: it enters the numerator (taken), never the on-time count, and the
|
||||
// slot stops reading missed. The same instants WITHOUT the pin read
|
||||
// ad-hoc + missed — the pin is the only thing that may move them.
|
||||
it("a pinned off-window take serves its slot as late — no on-time gain", () => {
|
||||
const pinned = [
|
||||
{
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(13, 0), // far past the 07:00 band's tail
|
||||
skipped: false,
|
||||
attributionSource: "USER_PIN" as const,
|
||||
},
|
||||
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
|
||||
];
|
||||
const tally = tallyComplianceFromLedger(
|
||||
pinned,
|
||||
twiceDaily,
|
||||
ctxFor(),
|
||||
from,
|
||||
nowEvening,
|
||||
nowEvening,
|
||||
);
|
||||
expect(tally.taken).toBe(2);
|
||||
expect(tally.takenOnTime).toBe(1); // only the 19:00 dose
|
||||
expect(tally.takenLate).toBe(1); // the pin counts late, never on-time
|
||||
expect(tally.missed).toBe(0);
|
||||
expect(tally.adHoc).toBe(0);
|
||||
expect(tally.rate).toBe(100);
|
||||
|
||||
// Control: the identical instants without the pin stay ad-hoc and the
|
||||
// 07:00 slot reads missed — AUTO attribution is untouched.
|
||||
const unpinned = pinned.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
}));
|
||||
const auto = tallyComplianceFromLedger(
|
||||
unpinned,
|
||||
twiceDaily,
|
||||
ctxFor(),
|
||||
from,
|
||||
nowEvening,
|
||||
nowEvening,
|
||||
);
|
||||
expect(auto.taken).toBe(1);
|
||||
expect(auto.takenLate).toBe(0);
|
||||
expect(auto.missed).toBe(1);
|
||||
expect(auto.adHoc).toBe(1);
|
||||
expect(auto.rate).toBe(50);
|
||||
});
|
||||
|
||||
// v1.15.19 — intraday pending rows. The today-projector mints pending
|
||||
// rows (takenAt = null, not skipped, not auto-missed) for every slot of
|
||||
// the day up front; from slot time onward the old ledger read them as
|
||||
|
||||
@@ -61,6 +61,14 @@ interface IntakeEvent {
|
||||
* row.
|
||||
*/
|
||||
autoMissed?: boolean;
|
||||
/**
|
||||
* v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately
|
||||
* pinned this off-window take onto its `scheduledFor` slot; the unified
|
||||
* ledger then binds it by anchor (taken-late, never on-time-washed)
|
||||
* instead of degrading it to ad-hoc when the takenAt sits outside the
|
||||
* band tail. Optional so legacy callers / fixtures default to AUTO.
|
||||
*/
|
||||
attributionSource?: "AUTO" | "USER_PIN";
|
||||
}
|
||||
|
||||
export type IntakeTimingClass =
|
||||
@@ -1283,6 +1291,9 @@ export function buildComplianceLedgerRows(
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
// v1.15.20 — a pinned take binds by anchor and tallies as taken-late
|
||||
// (slot served, no on-time gain) instead of ad_hoc + missed.
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
}));
|
||||
|
||||
return reconstructDoseHistory(bands, intakes, now);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildSlotBands, type SlotWindowInput } from "../attribution";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
suggestNearestSlot,
|
||||
type HistoryIntake,
|
||||
} from "../dose-history";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
@@ -231,6 +232,190 @@ describe("reconstructDoseHistory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.15.20 — deliberate user pins ("diesem Slot zuordnen"). A USER_PIN row
|
||||
// binds by its stored `scheduledFor` anchor like a skip, NOT by takenAt
|
||||
// band membership, so a pin outside the late tail stays on its slot. The
|
||||
// status never flatters: taken_late unless the takenAt happens to sit
|
||||
// inside the slot's own on-time band anyway.
|
||||
describe("pinned takes (USER_PIN)", () => {
|
||||
it("binds an off-band pinned take to its slot anchor as taken_late", () => {
|
||||
// 13:00 is past the 07:00 band's miss cutoff (11:00) — unpinned it
|
||||
// would be ad-hoc and the slot missed; the pin keeps it on the slot.
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[intake({ takenAt: at(13, 0), scheduledFor: at(7, 0), pinned: true })],
|
||||
nowEvening,
|
||||
);
|
||||
const morning = rows.find((r) => r.timeOfDay === "07:00");
|
||||
expect(morning?.status).toBe("taken_late");
|
||||
expect(morning?.pinned).toBe(true);
|
||||
expect(morning?.intake?.takenAt?.toISOString()).toBe(
|
||||
at(13, 0).toISOString(),
|
||||
);
|
||||
expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads taken_on_time only when the takenAt sits inside the on-time band anyway", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[intake({ takenAt: at(7, 30), scheduledFor: at(7, 0), pinned: true })],
|
||||
nowEvening,
|
||||
);
|
||||
const morning = rows.find((r) => r.timeOfDay === "07:00");
|
||||
expect(morning?.status).toBe("taken_on_time");
|
||||
expect(morning?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("a pin inside the late tail reads taken_late, never flattered", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[intake({ takenAt: at(9, 0), scheduledFor: at(7, 0), pinned: true })],
|
||||
nowEvening,
|
||||
);
|
||||
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe(
|
||||
"taken_late",
|
||||
);
|
||||
});
|
||||
|
||||
it("a deliberate pin claims the slot before a band-attributed take", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[
|
||||
intake({ id: "auto", takenAt: at(7, 0), scheduledFor: at(7, 0) }),
|
||||
intake({
|
||||
id: "pin",
|
||||
takenAt: at(13, 0),
|
||||
scheduledFor: at(7, 0),
|
||||
pinned: true,
|
||||
}),
|
||||
],
|
||||
nowEvening,
|
||||
);
|
||||
const morning = rows.find((r) => r.timeOfDay === "07:00");
|
||||
expect(morning?.intake?.id).toBe("pin");
|
||||
expect(morning?.status).toBe("taken_late");
|
||||
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc.map((r) => r.intake?.id)).toEqual(["auto"]);
|
||||
});
|
||||
|
||||
it("falls through to ad-hoc when the pinned slot is already claimed by a skip", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[
|
||||
intake({ id: "skip", skipped: true, scheduledFor: at(7, 0) }),
|
||||
intake({
|
||||
id: "pin",
|
||||
takenAt: at(13, 0),
|
||||
scheduledFor: at(7, 0),
|
||||
pinned: true,
|
||||
}),
|
||||
],
|
||||
nowEvening,
|
||||
);
|
||||
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped");
|
||||
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc.map((r) => r.intake?.id)).toEqual(["pin"]);
|
||||
});
|
||||
|
||||
it("falls through to ad-hoc when the pinned anchor matches no band (schedule changed)", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[
|
||||
intake({
|
||||
takenAt: at(15, 30),
|
||||
scheduledFor: at(15, 0),
|
||||
pinned: true,
|
||||
}),
|
||||
],
|
||||
nowEvening,
|
||||
);
|
||||
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc).toHaveLength(1);
|
||||
expect(adhoc[0].at.toISOString()).toBe(at(15, 30).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
// v1.15.20 — the due-context an ad-hoc take carries. With the test
|
||||
// geometry the 07:00 band runs 06:00–11:00 (on-time 06:00–08:00, 180min
|
||||
// tail), so its suggestion zone extends to 12:30 (tail + 50 %).
|
||||
describe("ad-hoc nearestSlot due-context", () => {
|
||||
it("an ad-hoc take inside an unserved slot's suggestion zone offers the pin", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })],
|
||||
nowEvening,
|
||||
);
|
||||
const adhoc = rows.find((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00");
|
||||
expect(adhoc?.nearestSlot?.at.toISOString()).toBe(at(7, 0).toISOString());
|
||||
expect(adhoc?.nearestSlot?.filled).toBe(false);
|
||||
});
|
||||
|
||||
it("a take past the suggestion zone keeps the context but reads filled (no pin offer)", () => {
|
||||
// 12:45 > 12:30 — outside the zone even though the slot is unserved.
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[intake({ takenAt: at(12, 45), scheduledFor: at(7, 0) })],
|
||||
nowEvening,
|
||||
);
|
||||
const adhoc = rows.find((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00");
|
||||
expect(adhoc?.nearestSlot?.filled).toBe(true);
|
||||
});
|
||||
|
||||
it("a second take near an already-served slot reads filled", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[
|
||||
intake({ id: "a", takenAt: at(7, 0), scheduledFor: at(7, 0) }),
|
||||
intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }),
|
||||
],
|
||||
nowEvening,
|
||||
);
|
||||
const adhoc = rows.find((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc?.intake?.id).toBe("b");
|
||||
expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00");
|
||||
expect(adhoc?.nearestSlot?.filled).toBe(true);
|
||||
});
|
||||
|
||||
it("an orphaned ad-hoc skip carries no due-context", () => {
|
||||
const morningOnly = bands.filter((b) => b.timeOfDay === "07:00");
|
||||
const rows = reconstructDoseHistory(
|
||||
morningOnly,
|
||||
[intake({ skipped: true, scheduledFor: at(19, 0) })],
|
||||
at(14, 0),
|
||||
);
|
||||
const adhoc = rows.find((r) => r.kind === "ad_hoc");
|
||||
expect(adhoc).toBeDefined();
|
||||
expect(adhoc?.nearestSlot).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suggestNearestSlot caps the zone at the next slot's on-time start", () => {
|
||||
// 07:00's tail+50 % would reach 12:30, but the 12:00 slot's on-time
|
||||
// window opens at 11:30 — the zones must stay disjoint.
|
||||
const tight = buildSlotBands([
|
||||
pointSlot(at(7, 0), "07:00"),
|
||||
{
|
||||
at: at(12, 0),
|
||||
timeOfDay: "12:00",
|
||||
onTimeStart: at(11, 30),
|
||||
onTimeEnd: at(12, 30),
|
||||
lateGraceMs: 180 * MIN,
|
||||
},
|
||||
]);
|
||||
const before = suggestNearestSlot(at(11, 15), tight, () => false);
|
||||
expect(before?.timeOfDay).toBe("07:00");
|
||||
expect(before?.filled).toBe(false);
|
||||
const after = suggestNearestSlot(at(11, 45), tight, () => false);
|
||||
expect(after?.timeOfDay).toBe("12:00");
|
||||
expect(after?.filled).toBe(false);
|
||||
});
|
||||
|
||||
it("suggestNearestSlot returns null without bands", () => {
|
||||
expect(suggestNearestSlot(at(9, 0), [], () => false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns rows in chronological order (slots + ad-hoc interleaved)", () => {
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
|
||||
@@ -177,6 +177,12 @@ export interface IntakeEventLike {
|
||||
* fixture that omits it keeps the user-skip-vs-taken-vs-missed contract.
|
||||
*/
|
||||
autoMissed?: boolean;
|
||||
/**
|
||||
* v1.15.20 — slot-binding provenance (`USER_PIN` = deliberate user pin).
|
||||
* Read by the band-ledger chip tally so a pinned take binds by anchor;
|
||||
* optional so legacy callers default to AUTO.
|
||||
*/
|
||||
attributionSource?: "AUTO" | "USER_PIN";
|
||||
}
|
||||
|
||||
/** One slot the schedule expected the user to dose. */
|
||||
|
||||
@@ -255,6 +255,8 @@ function ledgerChipCounts(
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
// v1.15.20 — pinned takes bind by anchor (taken-late, slot served).
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, intakes, asOf);
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
* a slot outside the queried window, not a user action);
|
||||
* - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`;
|
||||
* inside a band → that slot (on-time / late), outside every band → ad-hoc;
|
||||
* - a TAKEN intake the user PINNED onto a slot (`pinned`, v1.15.20 —
|
||||
* `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like
|
||||
* a skip, NOT by takenAt-band membership, so a pin outside the late tail
|
||||
* 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;
|
||||
* - each slot is claimed by at most one intake (first/best wins); extra
|
||||
* intakes near a filled slot fall through to ad-hoc.
|
||||
*
|
||||
@@ -39,6 +45,12 @@ export interface HistoryIntake {
|
||||
skipped: boolean;
|
||||
/** 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.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export type DoseHistoryStatus =
|
||||
@@ -49,6 +61,25 @@ export type DoseHistoryStatus =
|
||||
| "upcoming"
|
||||
| "ad_hoc";
|
||||
|
||||
/**
|
||||
* v1.15.20 — the due-context an ad-hoc row carries: the nearest scheduled
|
||||
* slot the take COULD belong to, so the UI can show "fällig gewesen: …" and
|
||||
* offer "diesem Slot zuordnen" when the slot is still unserved.
|
||||
*/
|
||||
export interface NearestSlotContext {
|
||||
/** The slot's canonical anchor instant. */
|
||||
at: Date;
|
||||
/** The slot's "HH:mm" label. */
|
||||
timeOfDay: string;
|
||||
/**
|
||||
* True when the slot cannot be offered for pinning: it is already served
|
||||
* by another intake (take / skip), OR the take falls outside the slot's
|
||||
* suggestion window (the context is then informational only — "fällig
|
||||
* gewesen" renders, the pin action does not).
|
||||
*/
|
||||
filled: boolean;
|
||||
}
|
||||
|
||||
export interface DoseHistoryRow {
|
||||
/** A scheduled slot, or a standalone off-schedule intake. */
|
||||
kind: "slot" | "ad_hoc";
|
||||
@@ -59,6 +90,19 @@ export interface DoseHistoryRow {
|
||||
status: DoseHistoryStatus;
|
||||
/** The intake attributed to this row, if any. */
|
||||
intake: HistoryIntake | null;
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* v1.15.20 — for an ad-hoc TAKE only: the nearest scheduled slot in the
|
||||
* window (preferring an unserved slot whose suggestion band contains the
|
||||
* take). Absent when the medication has no expected slots in the window
|
||||
* or the row is an orphaned skip.
|
||||
*/
|
||||
nearestSlot?: NearestSlotContext;
|
||||
}
|
||||
|
||||
/** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */
|
||||
@@ -76,16 +120,19 @@ export function reconstructDoseHistory(
|
||||
// Per-band claim: the intake attributed to it + the resolved status.
|
||||
const claim = new Map<
|
||||
SlotBand,
|
||||
{ intake: HistoryIntake; status: DoseHistoryStatus }
|
||||
{ intake: HistoryIntake; status: DoseHistoryStatus; pinned?: boolean }
|
||||
>();
|
||||
const adHoc: HistoryIntake[] = [];
|
||||
|
||||
// Partition: anchored rows (skip / auto-missed / pending — no takenAt) bind
|
||||
// by scheduledFor; taken rows attribute by real takenAt band membership.
|
||||
// Process anchored first so a deliberate skip/miss owns its slot before a
|
||||
// stray take could.
|
||||
// by scheduledFor; PINNED takes (v1.15.20) also bind by scheduledFor (the
|
||||
// pin IS the binding decision); unpinned taken rows attribute by real
|
||||
// takenAt band membership. Process anchored first so a deliberate
|
||||
// skip/miss owns its slot before a stray take could, then pins (a
|
||||
// deliberate decision beats band proximity), then band-attributed takes.
|
||||
const anchored = intakes.filter((i) => i.takenAt === null);
|
||||
const taken = intakes.filter((i) => i.takenAt !== null);
|
||||
const pinnedTaken = intakes.filter((i) => i.takenAt !== null && i.pinned);
|
||||
const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned);
|
||||
|
||||
for (const i of anchored) {
|
||||
const band = nearestAnchorBand(i.scheduledFor, bands);
|
||||
@@ -114,6 +161,28 @@ export function reconstructDoseHistory(
|
||||
// window; emitting the placeholder would fabricate a phantom ad-hoc row.
|
||||
}
|
||||
|
||||
// v1.15.20 — pinned takes bind by their stored slot anchor, NOT by
|
||||
// takenAt-band membership: the whole point of a pin is that the take sits
|
||||
// outside (or past the tail of) the band it belongs to. Status never
|
||||
// flatters: taken_late, unless the takenAt happens to sit inside the
|
||||
// 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) {
|
||||
const band = nearestAnchorBand(i.scheduledFor, bands);
|
||||
if (band && !claim.has(band)) {
|
||||
const t = (i.takenAt as Date).getTime();
|
||||
const onTime =
|
||||
t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime();
|
||||
claim.set(band, {
|
||||
intake: i,
|
||||
status: onTime ? "taken_on_time" : "taken_late",
|
||||
pinned: true,
|
||||
});
|
||||
} else {
|
||||
adHoc.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (const i of taken) {
|
||||
const matched = attributeIntakeToSlot(i.takenAt as Date, bands);
|
||||
if (matched && !claim.has(matched.band)) {
|
||||
@@ -135,6 +204,7 @@ export function reconstructDoseHistory(
|
||||
timeOfDay: band.timeOfDay,
|
||||
status: c.status,
|
||||
intake: c.intake,
|
||||
...(c.pinned && { pinned: true }),
|
||||
};
|
||||
}
|
||||
// Unfilled slot: missed only once the miss cutoff (the late tail's end)
|
||||
@@ -152,12 +222,21 @@ export function reconstructDoseHistory(
|
||||
});
|
||||
|
||||
for (const i of adHoc) {
|
||||
// v1.15.20 — an ad-hoc TAKE carries its due-context: the nearest slot it
|
||||
// could belong to, so the UI can show when the dose would have been due
|
||||
// and offer "diesem Slot zuordnen" when that slot is still unserved.
|
||||
// Orphaned skips (no takenAt) carry no take to attribute, so no context.
|
||||
const nearestSlot =
|
||||
i.takenAt !== null
|
||||
? suggestNearestSlot(i.takenAt, bands, (band) => claim.has(band))
|
||||
: null;
|
||||
rows.push({
|
||||
kind: "ad_hoc",
|
||||
at: i.takenAt ?? i.scheduledFor,
|
||||
timeOfDay: null,
|
||||
status: "ad_hoc",
|
||||
intake: i,
|
||||
...(nearestSlot && { nearestSlot }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,6 +244,68 @@ export function reconstructDoseHistory(
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — the slot an ad-hoc take most plausibly belongs to.
|
||||
*
|
||||
* Preference order:
|
||||
* 1. an UNSERVED slot whose suggestion band contains the take — the band is
|
||||
* the slot's capture zone (`onTimeStart`‥`overdueEnd`) extended past the
|
||||
* tail by 50 % of the tail's length, capped at the next slot's
|
||||
* `onTimeStart` so two adjacent slots' suggestion zones stay disjoint.
|
||||
* Nearest anchor wins among multiple matches;
|
||||
* 2. otherwise the nearest slot anchor overall (served or not) — the UI
|
||||
* still shows the "fällig gewesen" context, it just can't offer the pin.
|
||||
*
|
||||
* Pure; `isFilled` reports whether a band is already claimed by an intake.
|
||||
*/
|
||||
export function suggestNearestSlot(
|
||||
takenAt: Date,
|
||||
bands: SlotBand[],
|
||||
isFilled: (band: SlotBand) => boolean,
|
||||
): NearestSlotContext | null {
|
||||
if (bands.length === 0) return null;
|
||||
const t = takenAt.getTime();
|
||||
const sorted = [...bands].sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
let bestSuggest: { band: SlotBand; dist: number } | null = null;
|
||||
let bestAny: { band: SlotBand; dist: number } | null = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const band = sorted[i];
|
||||
const dist = Math.abs(t - band.at.getTime());
|
||||
if (bestAny === null || dist < bestAny.dist) {
|
||||
bestAny = { band, dist };
|
||||
}
|
||||
|
||||
if (isFilled(band)) continue;
|
||||
// Suggestion zone: the capture band plus half the late tail again,
|
||||
// capped at the following slot's on-time start (disjointness mirrors
|
||||
// `buildSlotBands`' overdue cap).
|
||||
const tailMs = band.overdueEnd.getTime() - band.onTimeEnd.getTime();
|
||||
let suggestEnd = band.overdueEnd.getTime() + tailMs * 0.5;
|
||||
const next = sorted[i + 1];
|
||||
if (next) suggestEnd = Math.min(suggestEnd, next.onTimeStart.getTime());
|
||||
if (t >= band.onTimeStart.getTime() && t <= suggestEnd) {
|
||||
if (bestSuggest === null || dist < bestSuggest.dist) {
|
||||
bestSuggest = { band, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pick = bestSuggest ?? bestAny;
|
||||
if (!pick) return null;
|
||||
return {
|
||||
at: pick.band.at,
|
||||
timeOfDay: pick.band.timeOfDay,
|
||||
// `filled` gates the pin offer. Only a preference-1 pick (an UNSERVED
|
||||
// slot whose suggestion window contains the take) is pinnable; the
|
||||
// nearest-anchor fallback is due-context only, so it reads filled even
|
||||
// when the slot itself is unserved — the pin never reaches across the
|
||||
// suggestion cap.
|
||||
filled: pick !== bestSuggest,
|
||||
};
|
||||
}
|
||||
|
||||
/** The band whose anchor is within epsilon of `instant`, nearest wins. */
|
||||
function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null {
|
||||
const t = instant.getTime();
|
||||
|
||||
@@ -64,6 +64,14 @@ export interface ApplyCanonicalSlotWriteInput {
|
||||
* upsert, so this is trusted here.
|
||||
*/
|
||||
injectionSite?: InjectionSiteKey | null;
|
||||
/**
|
||||
* v1.15.20 — slot-binding provenance to stamp on the row. `USER_PIN` on
|
||||
* the forced "diesem Slot zuordnen" paths, `AUTO` when a write
|
||||
* (re-)attributes by window band. Omitted → leave the column untouched on
|
||||
* an update / default AUTO on a create (pending echoes and skips never
|
||||
* carry a binding decision).
|
||||
*/
|
||||
attributionSource?: "AUTO" | "USER_PIN";
|
||||
}
|
||||
|
||||
export interface ApplyCanonicalSlotWriteResult {
|
||||
@@ -165,6 +173,7 @@ export async function applyCanonicalSlotWrite(
|
||||
idempotencyKey,
|
||||
createSource,
|
||||
injectionSite = null,
|
||||
attributionSource,
|
||||
} = input;
|
||||
|
||||
const rows = await findSlotRows(client, userId, medicationId, canonicalSlot);
|
||||
@@ -193,6 +202,8 @@ export async function applyCanonicalSlotWrite(
|
||||
// v1.8.5 — site only when the route resolved one (taken
|
||||
// injection, tracking on, validated allowed).
|
||||
...(injectionSite !== null && { injectionSite }),
|
||||
// v1.15.20 — binding provenance; absent → schema default AUTO.
|
||||
...(attributionSource !== undefined && { attributionSource }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
@@ -238,6 +249,7 @@ async function applyToExisting(
|
||||
isExplicitSkip,
|
||||
idempotencyKey,
|
||||
injectionSite = null,
|
||||
attributionSource,
|
||||
} = input;
|
||||
|
||||
const existingActioned = existing.takenAt !== null || existing.skipped;
|
||||
@@ -281,6 +293,10 @@ async function applyToExisting(
|
||||
// one (taken injection, tracking on, validated). Never clear a
|
||||
// previously-recorded site with a null on an idempotent re-post.
|
||||
...(injectionSite !== null && { injectionSite }),
|
||||
// v1.15.20 — binding provenance: set only when this write carries a
|
||||
// decision (pin / band re-attribution). A pending echo or skip never
|
||||
// overwrites a recorded USER_PIN.
|
||||
...(attributionSource !== undefined && { attributionSource }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
|
||||
@@ -91,6 +91,7 @@ describe("parseNotificationPrefs", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +100,7 @@ describe("parseNotificationPrefs", () => {
|
||||
medication: { clientManaged: false, deliveryDefault: "server" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +111,7 @@ describe("parseNotificationPrefs", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "client" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +138,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +153,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +165,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +177,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "client" },
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +190,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 8 },
|
||||
cycle: { clientManaged: false },
|
||||
coach: { nudgesEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* v1.15.20 — request schema for `PUT /api/coach/about-me`.
|
||||
*
|
||||
* Lives outside the route file so the OpenAPI registry
|
||||
* (`src/lib/openapi/routes.ts`) can import it without touching the
|
||||
* route module (route files may only export handlers + config), and
|
||||
* without dragging the Prisma-backed helper module
|
||||
* (`src/lib/ai/coach/about-me.ts`) into the generator script.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Hard cap enforced BEFORE encryption. */
|
||||
export const ABOUT_ME_MAX_CHARS = 4000;
|
||||
|
||||
export const aboutMePutSchema = z.object({
|
||||
/**
|
||||
* The user's free-text self-description. An empty / whitespace-only
|
||||
* value clears the stored text.
|
||||
*/
|
||||
aboutMe: z
|
||||
.string()
|
||||
.max(ABOUT_ME_MAX_CHARS, `Maximum ${ABOUT_ME_MAX_CHARS} characters`),
|
||||
});
|
||||
|
||||
export type AboutMePutInput = z.infer<typeof aboutMePutSchema>;
|
||||
@@ -27,6 +27,13 @@ export const registerSchema = z.object({
|
||||
* keep working.
|
||||
*/
|
||||
timezone: z.string().max(64).optional(),
|
||||
/**
|
||||
* v1.15.20 — registration invite token (`hlv_<64hex>`, 68 chars).
|
||||
* Only consulted when the operator has disabled open registration:
|
||||
* a valid, unexpired invite still admits the signup. Ignored when
|
||||
* registration is open.
|
||||
*/
|
||||
inviteToken: z.string().max(80).optional(),
|
||||
});
|
||||
|
||||
export const loginPasswordSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* v1.15.20 — request schema for `POST /api/admin/invites`.
|
||||
*
|
||||
* Lives outside the route file (and outside the Prisma-backed helper
|
||||
* `src/lib/auth/invite-token.ts`) so the OpenAPI registry can import it
|
||||
* without dragging `@/lib/db` into the generator script — the same
|
||||
* split as `src/lib/validations/about-me.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Hard ceiling on the admin-selectable invite lifetime (days). */
|
||||
export const INVITE_MAX_TTL_DAYS = 30;
|
||||
|
||||
/** Hard ceiling on multi-use invites — generous for a household / small
|
||||
* practice, far below anything that would re-open registration at scale. */
|
||||
export const INVITE_MAX_USES_CAP = 50;
|
||||
|
||||
export const inviteCreateSchema = z.object({
|
||||
/** Lifetime in days. The admin UI offers 7 / 14 / 30; the API accepts
|
||||
* any integer 1–30 so an operator can script shorter invites. */
|
||||
expiresInDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(INVITE_MAX_TTL_DAYS)
|
||||
.default(7),
|
||||
/** How many signups this invite admits. Default: single-use. */
|
||||
maxUses: z.number().int().min(1).max(INVITE_MAX_USES_CAP).default(1),
|
||||
});
|
||||
|
||||
export type InviteCreateInput = z.infer<typeof inviteCreateSchema>;
|
||||
@@ -730,19 +730,25 @@ export const updateIntakeEventSchema = z
|
||||
* take onto a chosen slot; the server validates the instant is a real
|
||||
* scheduled slot (422 otherwise). Absent → the edit re-runs band
|
||||
* attribution on the new `takenAt`.
|
||||
*
|
||||
* v1.15.20 — an explicit `null` UNPINS: the dose re-attributes by window
|
||||
* band on its (unchanged or edited) `takenAt` and the binding provenance
|
||||
* resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer
|
||||
* requires `takenAt` or `skipped` to change in the same request.
|
||||
*/
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Absent re-runs the default window-band attribution on the edited `takenAt`.",
|
||||
"Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "UpdateMedicationIntakeEventRequest",
|
||||
description:
|
||||
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.",
|
||||
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.",
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,20 @@ const cyclePrefsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive
|
||||
* Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an
|
||||
* opt-out surface — it only fires for users who kept the Coach enabled
|
||||
* AND have a working provider), flipped off from Settings →
|
||||
* Notifications. Sub-object form keeps the layout open for future
|
||||
* coach-notification knobs (quiet days, trigger selection, …).
|
||||
*/
|
||||
const coachPrefsSchema = z
|
||||
.object({
|
||||
nudgesEnabled: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* v1.7.0 — per-device delivery override schema for the device PATCH.
|
||||
* NULL clears the override (the device inherits the user-level roaming
|
||||
@@ -88,6 +102,7 @@ export const notificationPrefsSchema = z
|
||||
medication: medicationPrefsSchema,
|
||||
mood: moodPrefsSchema,
|
||||
cycle: cyclePrefsSchema,
|
||||
coach: coachPrefsSchema,
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -116,6 +131,13 @@ export interface NotificationPrefs {
|
||||
*/
|
||||
clientManaged: boolean;
|
||||
};
|
||||
coach: {
|
||||
/**
|
||||
* v1.15.20 — proactive Coach nudge opt-out. Default `true`; the
|
||||
* 05:15 nudge cron skips the user when `false`.
|
||||
*/
|
||||
nudgesEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +163,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
cycle: {
|
||||
clientManaged: false,
|
||||
},
|
||||
coach: {
|
||||
nudgesEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -181,6 +206,10 @@ export function resolveNotificationPrefs(
|
||||
...base.cycle,
|
||||
...(incoming.cycle ?? {}),
|
||||
},
|
||||
coach: {
|
||||
...base.coach,
|
||||
...(incoming.coach ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +235,7 @@ function applyDeliveryDefaultMapping(prefs: NotificationPrefs): NotificationPref
|
||||
},
|
||||
mood: { ...prefs.mood },
|
||||
cycle: { ...prefs.cycle },
|
||||
coach: { ...prefs.coach },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,11 +292,22 @@ export function resolveMoodReminderHour(raw: unknown): number {
|
||||
return parseNotificationPrefs(raw).mood.reminderHour;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — cron-side helper. Returns `true` when the proactive Coach
|
||||
* nudge may fire for this user (the default). Tolerates a null /
|
||||
* drifted prefs row. The nudge cron layers this on top of the
|
||||
* disableCoach / kill-switch / provider gates.
|
||||
*/
|
||||
export function resolveCoachNudgesEnabled(raw: unknown): boolean {
|
||||
return parseNotificationPrefs(raw).coach.nudgesEnabled;
|
||||
}
|
||||
|
||||
function cloneDefaults(): NotificationPrefs {
|
||||
return {
|
||||
medication: { ...DEFAULT_NOTIFICATION_PREFS.medication },
|
||||
mood: { ...DEFAULT_NOTIFICATION_PREFS.mood },
|
||||
cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle },
|
||||
coach: { ...DEFAULT_NOTIFICATION_PREFS.coach },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,5 +327,9 @@ function mergeOverDefaults(
|
||||
...DEFAULT_NOTIFICATION_PREFS.cycle,
|
||||
...(input.cycle ?? {}),
|
||||
},
|
||||
coach: {
|
||||
...DEFAULT_NOTIFICATION_PREFS.coach,
|
||||
...(input.coach ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user