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;
@@ -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
+11
View File
@@ -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);
+146 -5
View File
@@ -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 },
});
});
+25
View File
@@ -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>;
+7
View File
@@ -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({
+31
View File
@@ -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>;
+8 -2
View File
@@ -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.",
});
/**
+45
View File
@@ -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 ?? {}),
},
});
}