From 0cf9bc9348cedc1126e1f78e97292171faf882de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:39:55 +0200 Subject: [PATCH] feat(medications): attribute an off-schedule intake to its slot, visibly and reversibly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/medications/[id]/cadence/route.ts | 9 +- .../[id]/compliance/__tests__/route.test.ts | 4 +- .../api/medications/[id]/compliance/route.ts | 3 + .../medications/[id]/dose-history/route.ts | 18 ++ .../[id]/intake/[eventId]/route.ts | 40 +++- src/app/api/medications/[id]/intake/route.ts | 10 + src/app/api/medications/intake/bulk/route.ts | 8 + .../medications/dose-history-ledger.tsx | 178 ++++++++++++++++- .../analytics/__tests__/compliance.test.ts | 52 +++++ src/lib/analytics/compliance.ts | 11 ++ .../scheduling/__tests__/dose-history.test.ts | 185 ++++++++++++++++++ src/lib/medications/scheduling/cadence.ts | 6 + src/lib/medications/scheduling/compliance.ts | 2 + .../medications/scheduling/dose-history.ts | 151 +++++++++++++- src/lib/medications/scheduling/slot-upsert.ts | 16 ++ .../__tests__/notification-prefs.test.ts | 8 + src/lib/validations/about-me.ts | 25 +++ src/lib/validations/auth.ts | 7 + src/lib/validations/invite.ts | 31 +++ src/lib/validations/medication.ts | 10 +- src/lib/validations/notification-prefs.ts | 45 +++++ 21 files changed, 802 insertions(+), 17 deletions(-) create mode 100644 src/lib/validations/about-me.ts create mode 100644 src/lib/validations/invite.ts diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 708bba268..91911c24b 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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" }, }); diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 142293b92..86dcc6585 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -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, }); }); }); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 76c1face0..905bdd490 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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, }, }); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 9045525ea..11c47b7a2 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 22996a426..ee537ebbb 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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; } diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index e201ebf22..a522b20bd 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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; diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index a7f191f08..e95a7a504 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -260,6 +260,10 @@ async function postBulk(request: NextRequest): Promise { // 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 { 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 { 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 { 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 { idempotencyKey: entry.idempotencyKey ?? null, createSource: "API", injectionSite: resolvedInjectionSite, + attributionSource, }); if (applied.noDowngradeNoOp) { duplicates += 1; diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index c0de3fc59..d7bbf9d03 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -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(null); const [marking, setMarking] = useState(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)} /> ))} @@ -459,6 +517,18 @@ export function DoseHistoryLedger({ onCancel={() => setPendingDeleteId(null)} /> )} + + {pendingPin && ( + { + const pin = pendingPin; + setPendingPin(null); + void setAttribution(pin.eventId, pin.slotAt); + }} + onCancel={() => setPendingPin(null)} + /> + )} ); } @@ -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 (
  • )} + {row.pinned && ( + + {t("medications.detail.verlauf.pin.pinnedTag")} + + )} {showTakeDetail && (

    @@ -557,6 +652,17 @@ function LedgerRowItem({ })}

    )} + {dueContext && ( +

    + {t("medications.detail.verlauf.pin.dueContext", { + time: fmt.time(new Date(dueContext.at)), + delta: formatSlotDelta(intake!.takenAt!, dueContext.at), + })} +

    + )}
    @@ -624,6 +730,28 @@ function LedgerRowItem({ {t("medications.detail.verlauf.markSkipped")} )} + {canPin && ( + + onPin(intake!.id as string, dueContext!.at) + } + data-slot="ledger-pin" + > + + )} + {canUnpin && ( + onUnpin(intake!.id as string)} + data-slot="ledger-unpin" + > + + )} {intake?.id && ( <> void; + onCancel: () => void; +}) { + const { t } = useTranslations(); + return ( + !open && onCancel()}> + + + + {t("medications.detail.verlauf.pin.confirmTitle", { + time: slotLabel, + })} + + + {t("medications.detail.verlauf.pin.confirmBody")} + + + + + {t("medications.detail.verlauf.pin.confirmCancel")} + + { + e.preventDefault(); + onConfirm(); + }} + data-slot="ledger-pin-confirm-action" + > + {t("medications.detail.verlauf.pin.confirmAction")} + + + + + ); +} + /** Lightweight single-row delete confirm — the ledger does not need the full * bulk-delete state machine, just a guard. */ function DeleteRowConfirm({ diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 11b25c5d1..de1deea9f 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 36d1e1a69..1e62043ce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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); diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 0b52efba5..c635bcaad 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -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, diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index e608c23b7..911847de3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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. */ diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index cc8e9ccff..cdb602d11 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -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); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 20f53a2a7..2a8cac8fb 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -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(); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index a7c95552a..fa31ea53b 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -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; diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index f199b12d3..fdc570eb3 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -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 }, }); }); diff --git a/src/lib/validations/about-me.ts b/src/lib/validations/about-me.ts new file mode 100644 index 000000000..052397948 --- /dev/null +++ b/src/lib/validations/about-me.ts @@ -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; diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 761ba630f..329cd2878 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -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({ diff --git a/src/lib/validations/invite.ts b/src/lib/validations/invite.ts new file mode 100644 index 000000000..acccb5708 --- /dev/null +++ b/src/lib/validations/invite.ts @@ -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; diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 0325b3c44..cdfb37b3d 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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.", }); /** diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index f42c1f183..0fbc2cf84 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -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 ?? {}), + }, }); }