fix(vorsorge,coach): align reminders + coach chrome with the gold standard

Twice-daily BP protocol (RRULE BYHOUR=7,19) now fires at every clock
hour: byHourTimesOfDay expands BYHOUR into the engine's timesOfDay so
the label, RRULE, and engine output agree instead of collapsing to one
nudge at notifyHour.

Vorsorge surface:
- move create/edit into a ResponsiveSheet with a pinned Cancel/Save
  footer, matching Labs/Illness/Medications
- wire the unused PATCH support: Edit affordance opens the form
  pre-filled; an enable/disable Switch drives the enabled flag
- translate COACH-origin labels (the row stores an i18n key, not prose)
  and render a neutral Coach provenance badge + an until-<date> line
- surface origin + endsOn on the hook type
- pull-to-refresh + always-on rationale documented on the nav entry
- NativeSelect-vs-Radix choice documented as deliberate

Coach:
- expired COACH course-window reminders self-clean: the cron tick
  soft-deletes finished finite cadences instead of leaving dead rows
- accept-dedup gains a partial unique index (user, metric) where
  origin=COACH and not deleted, with a P2002 catch in the route
- per-user rate-limit bucket on the suggestion-action endpoint
- SUGGEST-REMINDER parser keeps prose written after the block
- document POST /api/coach/reminder-suggestions in OpenAPI

Coach chrome:
- HistoryRail hideHeading prop drops the duplicate Conversations
  heading on the page surface
- align the rail-tray strip inset to px-3 sm:px-4 so the dividers line
  up; reword the FAB inset comment

satisfyReminder closes the forward-only TOCTOU via a conditional
updateMany (count===0 no-op). Migration headers corrected to v1.18.1.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:11:08 +02:00
1 parent b28fa4d4a5
commit 7451e886f1
32 files changed
+983 -230

No files matched your search

+114
View File
@@ -4387,6 +4387,59 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/coach/reminder-suggestions:
post:
tags:
- Insights
summary: Act on a Coach cadence suggestion
description: 'v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated
(`requireModuleEnabled("coach")`); a disabled surface 403s. `accept` resolves the cadence from the closed
server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with
`duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or
stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth
via cookie or Bearer; the owner is narrowed from the session.'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
cadenceId:
type: string
minLength: 1
maxLength: 64
action:
type: string
enum:
- accept
- dismiss
- stop
required:
- cadenceId
- action
responses:
"200":
description: "A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`)."
content:
application/json:
schema:
$ref: "#/components/schemas/CoachReminderSuggestionResultOk"
"201":
description: An accept that created a new reminder.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachReminderSuggestionResultCreated"
"401": *a1
"403":
description: Coach surface (or the cadence's module) disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/admin/invites:
get:
tags:
@@ -15269,6 +15322,67 @@ components:
- data
- error
additionalProperties: false
CoachReminderSuggestionResultOk:
type: object
properties:
data:
$ref: "#/components/schemas/CoachReminderSuggestionResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachReminderSuggestionResult:
type: object
properties:
ok:
type: boolean
const: true
action:
type: string
enum:
- accept
- dismiss
- stop
reminder:
description: "On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a
prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`)."
anyOf:
- {}
- type: "null"
duplicate:
description: True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op).
type: boolean
required:
- ok
- action
additionalProperties: false
description: "Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 +
`duplicate: true` when one already exists); `dismiss`/`stop` return 200."
CoachReminderSuggestionResultCreated:
type: object
properties:
data:
$ref: "#/components/schemas/CoachReminderSuggestionResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AdminInviteList:
type: object
properties:
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Erinnern um",
"location": "Wo (optional)",
"locationPlaceholder": "z. B. Hausarzt, Labor",
"save": "Speichern"
"save": "Speichern",
"createTitle": "Neue Erinnerung",
"editTitle": "Erinnerung bearbeiten"
},
"pushTitle": "Vorsorge-Erinnerung",
"pushBody": "Fällig: {label}",
"pushLocation": "(bei {location})"
"pushLocation": "(bei {location})",
"edit": "Bearbeiten",
"originCoach": "Coach",
"enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren",
"until": "Bis {date}"
},
"labs": {
"title": "Laborwerte",
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Remind at",
"location": "Where (optional)",
"locationPlaceholder": "e.g. GP, Lab",
"save": "Save"
"save": "Save",
"createTitle": "New reminder",
"editTitle": "Edit reminder"
},
"pushTitle": "Preventive-care reminder",
"pushBody": "Time for: {label}",
"pushLocation": "(at {location})"
"pushLocation": "(at {location})",
"edit": "Edit",
"originCoach": "Coach",
"enabledToggleAria": "Enable or disable this reminder",
"until": "Until {date}"
},
"labs": {
"title": "Lab results",
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Recordar a las",
"location": "Dónde (opcional)",
"locationPlaceholder": "p. ej. Médico, Laboratorio",
"save": "Guardar"
"save": "Guardar",
"createTitle": "Nuevo recordatorio",
"editTitle": "Editar recordatorio"
},
"pushTitle": "Recordatorio de prevención",
"pushBody": "Toca: {label}",
"pushLocation": "(en {location})"
"pushLocation": "(en {location})",
"edit": "Editar",
"originCoach": "Coach",
"enabledToggleAria": "Activar o desactivar este recordatorio",
"until": "Hasta {date}"
},
"labs": {
"title": "Resultados de análisis",
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Rappeler à",
"location": "Où (facultatif)",
"locationPlaceholder": "ex. Médecin, Laboratoire",
"save": "Enregistrer"
"save": "Enregistrer",
"createTitle": "Nouveau rappel",
"editTitle": "Modifier le rappel"
},
"pushTitle": "Rappel de prévention",
"pushBody": "C'est l'heure : {label}",
"pushLocation": "(chez {location})"
"pushLocation": "(chez {location})",
"edit": "Modifier",
"originCoach": "Coach",
"enabledToggleAria": "Activer ou désactiver ce rappel",
"until": "Jusqu'au {date}"
},
"labs": {
"title": "Résultats d'analyses",
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Ricorda alle",
"location": "Dove (facoltativo)",
"locationPlaceholder": "es. Medico, Laboratorio",
"save": "Salva"
"save": "Salva",
"createTitle": "Nuovo promemoria",
"editTitle": "Modifica promemoria"
},
"pushTitle": "Promemoria di prevenzione",
"pushBody": "È ora di: {label}",
"pushLocation": "(presso {location})"
"pushLocation": "(presso {location})",
"edit": "Modifica",
"originCoach": "Coach",
"enabledToggleAria": "Attiva o disattiva questo promemoria",
"until": "Fino al {date}"
},
"labs": {
"title": "Risultati degli esami",
+8 -2
View File
@@ -6551,11 +6551,17 @@
"notifyHour": "Przypomnij o",
"location": "Gdzie (opcjonalnie)",
"locationPlaceholder": "np. Lekarz, Laboratorium",
"save": "Zapisz"
"save": "Zapisz",
"createTitle": "Nowe przypomnienie",
"editTitle": "Edytuj przypomnienie"
},
"pushTitle": "Przypomnienie profilaktyczne",
"pushBody": "Czas na: {label}",
"pushLocation": "(u {location})"
"pushLocation": "(u {location})",
"edit": "Edytuj",
"originCoach": "Coach",
"enabledToggleAria": "Włącz lub wyłącz to przypomnienie",
"until": "Do {date}"
},
"labs": {
"title": "Wyniki badań",
@@ -1,4 +1,4 @@
-- v1.19.0 — user-scoped biomarker catalog.
-- v1.18.1 — user-scoped biomarker catalog.
--
-- The lean v1.17.1 `lab_results` row stores analyte / unit / reference_low /
-- reference_high on EVERY row, so recording the same marker three times means
@@ -1,4 +1,4 @@
-- v1.19.0 — illness / condition journal.
-- v1.18.1 — illness / condition journal.
--
-- A CONDITION journal (not a flu log): any illness/condition kept for
-- oneself, with a retrospective lens. Retrospective + correlated, NEVER a
@@ -0,0 +1,32 @@
-- v1.18.1 — structural accept-dedup for COACH-minted measurement reminders.
--
-- The Coach can mint a `MeasurementReminder` (origin: COACH) from an
-- evidence-based cadence suggestion. The accept endpoint already does a
-- read-then-create dedup, but a read-then-create is racy: two near-simultaneous
-- accepts (a re-tap, or a stale card replayed) can both pass the existence
-- check and create twins. Close the race at the database with a PARTIAL unique
-- index: at most one LIVE COACH reminder per (user, metric). VORSORGE rows are
-- unaffected, and a soft-deleted (deleted_at IS NOT NULL) COACH row never
-- blocks a fresh accept of the same cadence.
--
-- Defensive pre-clean: should any duplicate live COACH rows already exist
-- (created before this constraint landed), keep the most recently created one
-- and tombstone the rest so the unique index can be built without error.
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY user_id, measurement_type
ORDER BY created_at DESC, id DESC
) AS rn
FROM "measurement_reminders"
WHERE origin = 'COACH' AND deleted_at IS NULL
)
UPDATE "measurement_reminders" m
SET deleted_at = NOW()
FROM ranked
WHERE m.id = ranked.id AND ranked.rn > 1;
CREATE UNIQUE INDEX "measurement_reminders_coach_metric_live_key"
ON "measurement_reminders" ("user_id", "measurement_type")
WHERE origin = 'COACH' AND deleted_at IS NULL;
+5
View File
@@ -3298,6 +3298,11 @@ model MeasurementReminder {
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
// v1.18.1 — structural accept-dedup for COACH-minted reminders: at most
// one LIVE COACH reminder per (user, metric). Enforced as a PARTIAL unique
// index (`WHERE origin = 'COACH' AND deleted_at IS NULL`) created by the
// 0172 migration — Prisma's schema DSL cannot express a filtered unique
// index, so the migration owns the constraint and this is a pointer to it.
@@map("measurement_reminders")
}
+52 -16
View File
@@ -19,8 +19,10 @@
*/
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -55,6 +57,18 @@ async function postAction(request: NextRequest): Promise<Response> {
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
// Per-user rate limit — the action card is one-tap, so a tight per-user
// bucket (userId, never IP) is the right granularity. Mirrors the
// labs/restore convention; a trust-violation can never widen it.
const rate = await checkRateLimit(
`coach:reminder-suggestion:${user.id}`,
30,
60_000,
);
if (!rate.allowed) {
return apiError("coach.suggestion.rateLimited", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
@@ -150,22 +164,44 @@ async function postAction(request: NextRequest): Promise<Response> {
// Field-by-field — no mass assignment. `origin: COACH` is the
// provenance the UI labels and the SUGGEST gate dedupes against.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: cadence.labelKey,
measurementType: cadence.measurementType,
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
endsOn,
origin: "COACH",
notifyHour: cadence.notifyHour,
location: null,
enabled: true,
nextDueAt,
},
});
let created;
try {
created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: cadence.labelKey,
measurementType: cadence.measurementType,
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
endsOn,
origin: "COACH",
notifyHour: cadence.notifyHour,
location: null,
enabled: true,
nextDueAt,
},
});
} catch (err) {
// Structural dedup backstop: the 0172 partial unique index
// (`origin = 'COACH' AND deleted_at IS NULL` per user+metric) wins the
// race the read-then-create above can lose. Treat the violation as the
// same idempotent "already have one" outcome.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
annotate({
action: { name: "coach.reminder.accept.duplicate" },
meta: { cadenceId, reason: "unique-violation" },
});
return apiSuccess(
{ ok: true, action: "accept", reminder: null, duplicate: true },
200,
);
}
throw err;
}
await auditLog("measurementReminder.create.coach", {
userId: user.id,
+20
View File
@@ -1,19 +1,38 @@
"use client";
import { useCallback } from "react";
import { Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
* feature surface for "wann muss ich was wo machen". Auth-gated; the
* section component owns the list + create flow.
*
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
* measurements): a pull invalidates the measurement-reminders read so the
* list + server-computed next-due repaint.
*/
export default function VorsorgePage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const queryClient = useQueryClient();
const refresh = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: queryKeys.measurementReminders(),
}),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refresh });
if (!mounted || isLoading) {
return (
@@ -25,6 +44,7 @@ export default function VorsorgePage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<VorsorgeSection enabled={isAuthenticated} variant="page" />
</div>
);
@@ -363,6 +363,10 @@ export function CoachConversation({
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
@@ -97,7 +97,11 @@ export function CoachDrawerBody({
two-height split). */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3"
// v1.18.1 (W-COACH-UI C4) — match the header band's `px-3 sm:px-4`
// inset so the rail-tray strip, the header, and the adjacent
// history-rail `<h2>` (px-4) all share the same horizontal divider
// line at sm+ rather than stepping in/out by a pixel.
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4"
>
<Button
type="button"
@@ -33,12 +33,21 @@ export interface HistoryRailProps {
* default layout (currently the drawer wraps it in an `<aside>`).
*/
className?: string;
/**
* v1.18.1 — suppress the rail's own `<h3>` "Conversations" label. The
* page surface wraps the rail in an `<aside>` that already renders an
* `<h2>` band heading with the same text, so the inline rail must not
* stack a second identical heading. Defaults to `false` (the mobile
* tray + standalone uses keep the heading).
*/
hideHeading?: boolean;
}
export function HistoryRail({
activeId,
onSelect,
className,
hideHeading = false,
}: HistoryRailProps) {
const { t } = useTranslations();
const { conversations, isLoading } = useCoachConversations();
@@ -78,16 +87,18 @@ export function HistoryRail({
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
{t("insights.coach.historyTitle")}
</h3>
{!hideHeading && (
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
{t("insights.coach.historyTitle")}
</h3>
)}
<div className="relative">
<Search
aria-hidden="true"
+7 -7
View File
@@ -157,13 +157,13 @@ export function LayoutCoachFab() {
// safe-area inset on mobile; plain bottom offset once the
// bottom-nav hides (`md:hidden` on the nav, so `md:` here —
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
// v1.18.1 (W-COACH-UI C5) — the corner inset is now symmetric:
// the right inset equals the bottom inset at each breakpoint
// (`right-6`/`bottom-6` mobile-band, `md:right-8`/`md:bottom-8`
// desktop). The FAB was hugging the right edge (`right-6`)
// while the desktop bottom sat at the same `6`, but it read as
// off-centre against the larger mobile bottom band; pulling it
// a touch further left + matching both offsets evens the gap.
// v1.18.1 (W-COACH-UI C5) — corner inset: on desktop the right and
// bottom insets match (`md:right-8` / `md:bottom-8`) so the FAB
// sits symmetrically in the corner. On mobile the right inset is
// `right-6` but the bottom is deliberately larger — it clears the
// 64 px bottom-nav plus the home-indicator safe-area inset
// (`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]`), so the
// gap is asymmetric there by design, not drift.
"fixed right-6 z-40 size-14 rounded-full shadow-lg md:right-8",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-8",
// Dark glyph on the purple/pink gradient — white sat at
+7
View File
@@ -123,6 +123,13 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vorsorge",
icon: Stethoscope,
tourId: "nav-vorsorge",
// v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike
// labs / illness / cycle (opt-in clinical-spine verticals born off by
// default), preventive-care reminders are a CORE surface available to
// every account from birth: a reminder can target core vitals
// (weight / BP / pulse) that are never behind a module toggle, and a
// free-text "Großes Blutbild" reminder belongs to no module at all.
// Gating the entry would orphan reminders the user can still create.
},
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
@@ -18,6 +18,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
useMeasurementReminders: () => remindersMock(),
useMeasurementReminderMutations: () => ({
create: { mutate: vi.fn(), isPending: false },
update: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
}),
@@ -71,4 +72,35 @@ describe("<VorsorgeSection> loading + empty", () => {
expect(html).not.toContain('data-slot="empty-state"');
expect(html).not.toContain('data-slot="vorsorge-loading"');
});
it("translates a COACH-origin label i18n key and shows the neutral badge", () => {
const reminder: MeasurementReminder = {
id: "c1",
// A COACH row stores the cadence preset's i18n KEY in `label`.
label: "coach.reminderSuggestion.cadence.bp722",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1",
anchorDate: null,
endsOn: "2030-01-08T00:00:00.000Z",
origin: "COACH",
nextDueAt: null,
notifyHour: 7,
location: null,
lastSatisfiedAt: null,
enabled: true,
createdAt: "2030-01-01T00:00:00.000Z",
updatedAt: "2030-01-01T00:00:00.000Z",
} as MeasurementReminder;
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
const html = render(<VorsorgeSection />);
// The raw i18n key must never leak; the resolved EN string shows instead.
expect(html).not.toContain("coach.reminderSuggestion.cadence.bp722");
expect(html).toContain(
"Measure your blood pressure twice a day for a week",
);
// Neutral "Coach" provenance badge + an "until <date>" course line.
expect(html).toContain("Coach");
expect(html).toContain("Until");
});
});
@@ -11,11 +11,18 @@
*
* The server is authoritative for `nextDueAt`; this component renders it
* relative to "now" but never recomputes the cadence.
*
* v1.18.1 — the create/edit form moved into a ResponsiveSheet with a
* pinned Cancel/Save footer (matching Labs / Illness / Medications), an
* Edit affordance wires the existing PATCH support, an enable/disable
* toggle drives the `enabled` flag, and a COACH-origin reminder renders a
* neutral provenance badge + an "until <date>" course-window line while
* translating its i18n-key label.
*/
import { useState } from "react";
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
import { CalendarClock, CheckCircle2, Pencil, Plus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { DeleteButton } from "@/components/data-list";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +38,9 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -88,6 +97,42 @@ function relativeDueKey(
return { key: "nextDue.inDays", days: deltaDays };
}
/**
* v1.18.1 — a COACH-minted reminder stores an i18n KEY in `label` (the
* cadence preset's `labelKey`), not free prose. Resolve it through `t()`;
* a user-created (VORSORGE) reminder carries free text and renders verbatim.
*/
function resolveReminderLabel(
reminder: MeasurementReminder,
t: (key: string) => string,
): string {
if (reminder.origin === "COACH") {
const translated = t(reminder.label);
// `t()` echoes the key back on a miss; fall back to the raw value so a
// future catalog addition never surfaces a bare key.
return translated === reminder.label ? reminder.label : translated;
}
return reminder.label;
}
interface FormState {
label: string;
kind: "type" | "freeText";
measurementType: string;
intervalDays: number;
notifyHour: number;
location: string;
}
const EMPTY_FORM: FormState = {
label: "",
kind: "type",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
notifyHour: 9,
location: "",
};
export function VorsorgeSection({
enabled = true,
variant = "settings",
@@ -99,23 +144,20 @@ export function VorsorgeSection({
* (`<h1>` + subtitle + primary add button) so the standalone `/vorsorge`
* surface matches its peers (labs, mood, medications, cycle). The
* add-entry affordance is identical in both — a primary button with a
* `Plus` glyph that toggles the inline create form.
* `Plus` glyph that opens the create sheet.
*/
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
const { create, remove, satisfy } = useMeasurementReminderMutations();
const [showForm, setShowForm] = useState(false);
const { create, update, remove, satisfy } =
useMeasurementReminderMutations();
// Form state.
const [label, setLabel] = useState("");
const [kind, setKind] = useState<"type" | "freeText">("type");
const [measurementType, setMeasurementType] =
useState<string>("BLOOD_PRESSURE_SYS");
const [intervalDays, setIntervalDays] = useState<number>(7);
const [notifyHour, setNotifyHour] = useState<number>(9);
const [location, setLocation] = useState("");
// `null` = sheet closed; "new" = create; a reminder = edit pre-filled.
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
null,
);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// Wall-clock anchor for the relative-due labels, captured once at mount
// via a lazy state initializer so the impure Date.now() stays out of
@@ -124,43 +166,60 @@ export function VorsorgeSection({
// mount-time snapshot is plenty fresh.
const [now] = useState(() => Date.now());
function resetForm() {
setLabel("");
setKind("type");
setMeasurementType("BLOOD_PRESSURE_SYS");
setIntervalDays(7);
setNotifyHour(9);
setLocation("");
const sheetOpen = editing !== null;
const isEdit = editing !== null && editing !== "new";
function openCreate() {
setForm(EMPTY_FORM);
setEditing("new");
}
function openEdit(reminder: MeasurementReminder) {
setForm({
label: resolveReminderLabel(reminder, t),
kind: reminder.measurementType ? "type" : "freeText",
measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS",
intervalDays: reminder.intervalDays ?? 7,
notifyHour: reminder.notifyHour,
location: reminder.location ?? "",
});
setEditing(reminder);
}
function closeSheet() {
setEditing(null);
setForm(EMPTY_FORM);
}
function submit() {
const trimmed = label.trim();
const trimmed = form.label.trim();
if (!trimmed) return;
create.mutate(
{
label: trimmed,
measurementType: kind === "type" ? measurementType : null,
intervalDays,
notifyHour,
location: location.trim() || null,
},
{
onSuccess: () => {
resetForm();
setShowForm(false);
},
},
);
const body = {
label: trimmed,
measurementType: form.kind === "type" ? form.measurementType : null,
intervalDays: form.intervalDays,
notifyHour: form.notifyHour,
location: form.location.trim() || null,
};
if (isEdit) {
update.mutate(
{ id: (editing as MeasurementReminder).id, body },
{ onSuccess: closeSheet },
);
} else {
create.mutate(body, { onSuccess: closeSheet });
}
}
const saving = create.isPending || update.isPending;
// The shared primary add-entry affordance — identical in both variants so
// the control reads the same on the standalone page and the settings card.
const addButton = (
<Button
type="button"
variant={showForm ? "outline" : "default"}
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setShowForm((v) => !v)}
onClick={openCreate}
>
<Plus className="h-4 w-4" />
{t("measurementReminders.addButton")}
@@ -168,10 +227,7 @@ export function VorsorgeSection({
);
return (
<section
aria-labelledby="vorsorge-section-title"
className="space-y-4"
>
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
{variant === "page" ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -197,142 +253,165 @@ export function VorsorgeSection({
/>
)}
{showForm && (
<Card>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
</Label>
<Input
id="vorsorge-label"
value={label}
maxLength={120}
placeholder={t("measurementReminders.form.labelPlaceholder")}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<ResponsiveSheet
open={sheetOpen}
onOpenChange={(open) => {
if (!open) closeSheet();
}}
title={t(
isEdit
? "measurementReminders.form.editTitle"
: "measurementReminders.form.createTitle",
)}
description={t("measurementReminders.sectionDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={closeSheet}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={submit}
disabled={!form.label.trim() || saving}
>
{t("measurementReminders.form.save")}
</Button>
</>
}
>
{/* v1.18.1 — the cadence/type/hour pickers use NativeSelect (not the
Radix Select labs/illness use) deliberately: these are long,
grouped, scalar option lists (24 hours, a 15-type grouped metric
list) where the OS-native picker is faster on touch and avoids a
tall scrolling Radix popover inside the bottom-sheet. The form
fields below stay NativeSelect by design, not drift. */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
</Label>
<Input
id="vorsorge-label"
value={form.label}
maxLength={120}
placeholder={t("measurementReminders.form.labelPlaceholder")}
onChange={(e) =>
setForm((f) => ({ ...f, label: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-kind">
{t("measurementReminders.form.kind")}
</Label>
<NativeSelect
id="vorsorge-kind"
value={form.kind}
onChange={(e) =>
setForm((f) => ({
...f,
kind: e.target.value as "type" | "freeText",
}))
}
>
<option value="type">
{t("measurementReminders.form.kindType")}
</option>
<option value="freeText">
{t("measurementReminders.form.kindFreeText")}
</option>
</NativeSelect>
</div>
{form.kind === "type" && (
<div className="space-y-2">
<Label htmlFor="vorsorge-kind">
{t("measurementReminders.form.kind")}
<Label htmlFor="vorsorge-type">
{t("measurementReminders.form.measurementType")}
</Label>
<NativeSelect
id="vorsorge-kind"
value={kind}
id="vorsorge-type"
value={form.measurementType}
onChange={(e) =>
setKind(e.target.value as "type" | "freeText")
setForm((f) => ({ ...f, measurementType: e.target.value }))
}
>
<option value="type">
{t("measurementReminders.form.kindType")}
</option>
<option value="freeText">
{t("measurementReminders.form.kindFreeText")}
</option>
{TYPE_GROUPS.map(({ group, types }) => (
<optgroup
key={group}
label={t(`measurementReminders.typeGroups.${group}`)}
>
{types.map((type) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
))}
</optgroup>
))}
</NativeSelect>
</div>
)}
{kind === "type" && (
<div className="space-y-2">
<Label htmlFor="vorsorge-type">
{t("measurementReminders.form.measurementType")}
</Label>
<NativeSelect
id="vorsorge-type"
value={measurementType}
onChange={(e) => setMeasurementType(e.target.value)}
>
{TYPE_GROUPS.map(({ group, types }) => (
<optgroup
key={group}
label={t(`measurementReminders.typeGroups.${group}`)}
>
{types.map((type) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
))}
</optgroup>
))}
</NativeSelect>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-interval">
{t("measurementReminders.form.cadence")}
</Label>
<NativeSelect
id="vorsorge-interval"
value={String(intervalDays)}
onChange={(e) => setIntervalDays(Number(e.target.value))}
>
{INTERVAL_PRESETS.map((days) => (
<option key={days} value={days}>
{t("measurementReminders.cadence.everyNDays", { days })}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-hour">
{t("measurementReminders.form.notifyHour")}
</Label>
<NativeSelect
id="vorsorge-hour"
value={String(notifyHour)}
onChange={(e) => setNotifyHour(Number(e.target.value))}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{`${h.toString().padStart(2, "0")}:00`}
</option>
))}
</NativeSelect>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-location">
{t("measurementReminders.form.location")}
<Label htmlFor="vorsorge-interval">
{t("measurementReminders.form.cadence")}
</Label>
<Input
id="vorsorge-location"
value={location}
maxLength={200}
placeholder={t(
"measurementReminders.form.locationPlaceholder",
)}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
resetForm();
setShowForm(false);
}}
<NativeSelect
id="vorsorge-interval"
value={String(form.intervalDays)}
onChange={(e) =>
setForm((f) => ({
...f,
intervalDays: Number(e.target.value),
}))
}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={submit}
disabled={!label.trim() || create.isPending}
>
{t("measurementReminders.form.save")}
</Button>
{INTERVAL_PRESETS.map((days) => (
<option key={days} value={days}>
{t("measurementReminders.cadence.everyNDays", { days })}
</option>
))}
</NativeSelect>
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
<Label htmlFor="vorsorge-hour">
{t("measurementReminders.form.notifyHour")}
</Label>
<NativeSelect
id="vorsorge-hour"
value={String(form.notifyHour)}
onChange={(e) =>
setForm((f) => ({ ...f, notifyHour: Number(e.target.value) }))
}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{`${h.toString().padStart(2, "0")}:00`}
</option>
))}
</NativeSelect>
</div>
</div>
{isLoading && !showForm && (
<div className="space-y-2">
<Label htmlFor="vorsorge-location">
{t("measurementReminders.form.location")}
</Label>
<Input
id="vorsorge-location"
value={form.location}
maxLength={200}
placeholder={t("measurementReminders.form.locationPlaceholder")}
onChange={(e) =>
setForm((f) => ({ ...f, location: e.target.value }))
}
/>
</div>
</div>
</ResponsiveSheet>
{isLoading && (
<div className="space-y-3" data-slot="vorsorge-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
@@ -348,13 +427,13 @@ export function VorsorgeSection({
</div>
)}
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
{!isLoading && (reminders?.length ?? 0) === 0 && (
<EmptyState
icon={<Plus className="size-6" />}
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
<Button type="button" onClick={() => setShowForm(true)}>
<Button type="button" onClick={openCreate}>
{t("measurementReminders.addButton")}
</Button>
}
@@ -369,7 +448,11 @@ export function VorsorgeSection({
now={now}
onSatisfy={() => satisfy.mutate(reminder.id)}
onRemove={() => remove.mutate(reminder.id)}
busy={satisfy.isPending || remove.isPending}
onEdit={() => openEdit(reminder)}
onToggleEnabled={(next) =>
update.mutate({ id: reminder.id, body: { enabled: next } })
}
busy={satisfy.isPending || remove.isPending || update.isPending}
/>
))}
</ul>
@@ -382,15 +465,20 @@ function VorsorgeCard({
now,
onSatisfy,
onRemove,
onEdit,
onToggleEnabled,
busy,
}: {
reminder: MeasurementReminder;
now: number;
onSatisfy: () => void;
onRemove: () => void;
onEdit: () => void;
onToggleEnabled: (next: boolean) => void;
busy: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const due = relativeDueKey(reminder.nextDueAt, now);
const cadence =
reminder.intervalDays != null
@@ -400,6 +488,7 @@ function VorsorgeCard({
: reminder.rrule
? t("measurementReminders.cadence.custom")
: "";
const isCoach = reminder.origin === "COACH";
return (
<li>
@@ -407,15 +496,29 @@ function VorsorgeCard({
through the discreet badge below. */}
<Card>
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<span className="truncate font-medium">{reminder.label}</span>
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
<span className="truncate font-medium">
{resolveReminderLabel(reminder, t)}
</span>
{isCoach && (
// Neutral provenance badge — no status colour per the rule.
<Badge variant="outline">
{t("measurementReminders.originCoach")}
</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</CardTitle>
<CardAction className="flex gap-1">
<CardAction className="flex items-center gap-1">
<Switch
checked={reminder.enabled}
onCheckedChange={onToggleEnabled}
disabled={busy}
aria-label={t("measurementReminders.enabledToggleAria")}
/>
<Button
type="button"
size="sm"
@@ -427,6 +530,17 @@ function VorsorgeCard({
<CheckCircle2 className="mr-1 h-4 w-4" />
{t("measurementReminders.markDone")}
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={onEdit}
disabled={busy}
className="size-9"
aria-label={t("measurementReminders.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={onRemove}
title={t("measurementReminders.deleteConfirmTitle")}
@@ -444,6 +558,13 @@ function VorsorgeCard({
</Badge>
{cadence}
</p>
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.until", {
date: fmt.date(new Date(reminder.endsOn)),
})}
</p>
)}
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
+12
View File
@@ -21,6 +21,18 @@ export interface MeasurementReminder {
intervalDays: number | null;
rrule: string | null;
anchorDate: string | null;
/**
* v1.18.1 — optional course-window end (ISO-8601). NULL ⇒ open-ended; a
* Coach-suggested time-boxed protocol carries a non-NULL value and the
* UI renders an "until <date>" line.
*/
endsOn: string | null;
/**
* v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from
* a Coach cadence suggestion). The UI labels the source and translates a
* COACH row's `label` (which carries an i18n key, not free text).
*/
origin: "VORSORGE" | "COACH";
notifyHour: number;
location: string | null;
nextDueAt: string | null;
@@ -26,6 +26,28 @@ describe("parseSuggestReminder", () => {
expect(r.malformed).toBe(false);
});
it("keeps prose written AFTER the block (text-before + text-after)", () => {
const raw =
"Before the block.\n" +
"---SUGGEST-REMINDER---\ncadence: bp_7_2_2\n---END---\n" +
"And a closing thought after it.";
const r = parseSuggestReminder(raw);
expect(r.cadence?.id).toBe("bp_7_2_2");
expect(r.prose).toContain("Before the block.");
expect(r.prose).toContain("And a closing thought after it.");
expect(r.prose).not.toContain("SUGGEST-REMINDER");
});
it("keeps trailing prose even on a malformed block", () => {
const raw =
"Before.\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---\nAfter.";
const r = parseSuggestReminder(raw);
expect(r.malformed).toBe(true);
expect(r.prose).toContain("Before.");
expect(r.prose).toContain("After.");
expect(r.prose).not.toContain("SUGGEST-REMINDER");
});
it("strips quotes/backticks the model may add around the id", () => {
const raw =
"text\n---SUGGEST-REMINDER---\ncadence: `weight_daily`\n---END---";
+11 -1
View File
@@ -166,7 +166,7 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult {
return { prose: raw, cadence: null, malformed: false };
}
const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
const before = raw.slice(0, openIdx);
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
// Cap the payload before parsing regardless of whether the close marker
@@ -178,6 +178,16 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult {
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
: bodyRaw;
// Reconstruct the prose as text-before-open + text-after-close so a model
// that writes a sentence AFTER the block (or after a malformed one) keeps
// it. Only the sentinel block itself is stripped; when the close marker is
// missing there is no trailing text to recover.
const after =
closeRel === -1
? ""
: afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
const cadenceId = extractCadenceId(body);
if (cadenceId && isCadenceId(cadenceId)) {
return { prose, cadence: CADENCE_CATALOG[cadenceId], malformed: false };
@@ -146,6 +146,27 @@ function makePrisma(opts: {
return { id: where.id, ...data };
},
),
// v1.18.1 — two callers use `updateMany` against this model now:
// - `satisfyReminder` (conditional forward-only write) — has `where.id`
// and should record the write so the satisfy assertions still see it.
// - the tick's expired-COACH cleanup sweep — no `where.id`; record
// nothing and report zero rows cleaned (the fixtures carry no
// expired COACH rows).
updateMany: vi.fn(
async ({
where,
data,
}: {
where: { id?: string };
data: Record<string, unknown>;
}) => {
if (where.id) {
updates.push({ id: where.id, data });
return { count: 1 };
}
return { count: 0 };
},
),
},
measurement: {
findFirst: vi.fn(async () => opts.measurementMatch ?? null),
@@ -57,6 +57,14 @@ function makePrisma(opts: {
return { id: where.id, ...data };
},
),
// v1.18.1 — `satisfyReminder` now writes via a conditional `updateMany`
// (forward-only TOCTOU close). Record the write + report a hit.
updateMany: vi.fn(
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
updates.push({ id: where.id, data });
return { count: 1 };
},
),
},
measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) },
labResult: { findFirst: vi.fn(async () => opts.lab ?? null) },
+34
View File
@@ -86,9 +86,37 @@ export interface MeasurementReminderSummary {
skippedModuleDisabled: number;
skippedClientManaged: number;
skippedNoChannel: number;
/** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */
expiredCleaned: number;
failed: number;
}
/**
* v1.18.1 — soft-delete COACH course-window reminders whose finite window
* has elapsed. A Coach-suggested protocol (the ESH/AHA 7-day BP cadence)
* carries a non-NULL `endsOn`; once the recurrence engine walks past it the
* row's `nextDueAt` is stamped NULL (no future occurrence). Such a row can
* never fire again, so it would otherwise linger forever in the Vorsorge
* list as a dead "completed course". Tombstone it so the surface stays
* clean. Scoped to `origin: COACH` so a user's open-ended VORSORGE row with
* a one-shot `endsOn` is never touched without intent.
*/
async function cleanupExpiredCoachReminders(
prisma: PrismaClient,
now: Date,
): Promise<number> {
const result = await prisma.measurementReminder.updateMany({
where: {
deletedAt: null,
origin: "COACH",
endsOn: { not: null, lt: now },
nextDueAt: null,
},
data: { deletedAt: now },
});
return result.count;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
@@ -171,9 +199,15 @@ export async function runMeasurementReminderTick(
skippedModuleDisabled: 0,
skippedClientManaged: 0,
skippedNoChannel: 0,
expiredCleaned: 0,
failed: 0,
};
// v1.18.1 — sweep expired COACH course-window reminders before scanning
// the due set, so a self-expired protocol drops out of the list and the
// dispatch loop never re-evaluates a row that can never fire again.
summary.expiredCleaned = await cleanupExpiredCoachReminders(prisma, now);
// Bound the scan to reminders that could plausibly fire this tick so Postgres
// uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the
// whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the
@@ -10,20 +10,27 @@ import { satisfyReminder } from "../satisfy";
const TZ = "Europe/Berlin";
function makePrisma() {
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
function makePrisma(updateManyCount = 1) {
const updates: Array<{
where: Record<string, unknown>;
data: Record<string, unknown>;
}> = [];
const prisma = {
measurementReminder: {
update: vi.fn(
// v1.18.1 — `satisfyReminder` now writes via a conditional
// `updateMany` so the forward-only guard re-asserts at the DB row
// (close the cron-vs-worker TOCTOU). `updateManyCount` simulates a
// racing writer having already advanced the row (count === 0).
updateMany: vi.fn(
async ({
where,
data,
}: {
where: { id: string };
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => {
updates.push({ id: where.id, data });
return { id: where.id, ...data };
if (updateManyCount > 0) updates.push({ where, data });
return { count: updateManyCount };
},
),
},
@@ -66,6 +73,35 @@ describe("satisfyReminder", () => {
);
});
it("re-asserts the forward-only invariant in the conditional updateMany", async () => {
const { prisma } = makePrisma();
const at = new Date("2026-06-14T18:00:00Z");
await satisfyReminder(prisma as never, reminder(), TZ, at);
const call = prisma.measurementReminder.updateMany.mock
.calls[0][0] as unknown as {
where: { id: string; OR: unknown[] };
};
expect(call.where.id).toBe("r1");
expect(call.where.OR).toEqual([
{ lastSatisfiedAt: null },
{ lastSatisfiedAt: { lt: at } },
]);
});
it("treats a racing-writer updateMany count of 0 as a forward-only no-op", async () => {
const { prisma } = makePrisma(0);
const at = new Date("2026-06-14T18:00:00Z");
const result = await satisfyReminder(prisma as never, reminder(), TZ, at);
// The in-memory guard passed (lastSatisfiedAt null) but the DB write
// matched no row — a concurrent satisfy already advanced it. No-op.
expect(result.satisfied).toBe(false);
expect(result.nextDueAt).toBeNull();
});
it("advances when the event is strictly after the existing lastSatisfiedAt", async () => {
const { prisma, updates } = makePrisma();
const prev = new Date("2026-06-10T08:00:00Z");
@@ -96,7 +132,7 @@ describe("satisfyReminder", () => {
expect(result.satisfied).toBe(false);
expect(result.nextDueAt).toBeNull();
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled();
expect(updates).toHaveLength(0);
});
@@ -112,6 +148,6 @@ describe("satisfyReminder", () => {
);
expect(result.satisfied).toBe(false);
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@
import { describe, it, expect } from "vitest";
import {
byHourTimesOfDay,
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "../scheduling";
@@ -97,4 +98,51 @@ describe("computeReminderNextDueAt", () => {
expect(due!.getUTCFullYear()).toBe(2027);
expect(due!.getUTCMonth()).toBe(5); // June (0-indexed)
});
// v1.18.1 — the twice-daily BP protocol (BYHOUR=7,19) must fire at BOTH
// clock hours, not collapse to the single notifyHour.
it("rrule BYHOUR=7,19: next due lands on the earliest unfired clock hour", () => {
const reminder = base({
rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1",
anchorDate: new Date("2026-06-10T00:00:00Z"),
createdAt: new Date("2026-06-10T00:00:00Z"),
notifyHour: 7,
});
// After 06:00 Berlin (04:00Z) on the anchor day → the 07:00 slot.
const morning = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-10T04:00:00Z"),
);
// 07:00 Berlin == 05:00Z (DST).
expect(morning!.toISOString()).toBe("2026-06-10T05:00:00.000Z");
// After the morning slot but before evening → the 19:00 slot SAME day.
const evening = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-10T06:00:00Z"),
);
// 19:00 Berlin == 17:00Z (DST).
expect(evening!.toISOString()).toBe("2026-06-10T17:00:00.000Z");
});
});
describe("byHourTimesOfDay", () => {
it("parses BYHOUR into sorted, deduped HH:00 strings", () => {
expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=19,7,7;INTERVAL=1")).toEqual([
"07:00",
"19:00",
]);
});
it("returns null for an rrule without BYHOUR", () => {
expect(byHourTimesOfDay("FREQ=YEARLY")).toBeNull();
expect(byHourTimesOfDay(null)).toBeNull();
});
it("drops out-of-range hours and returns null when none survive", () => {
expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=24,99")).toBeNull();
expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=6,30")).toEqual(["06:00"]);
});
});
+18 -2
View File
@@ -92,10 +92,26 @@ export async function satisfyReminder(
satisfiedAt,
);
await prisma.measurementReminder.update({
where: { id: reminder.id },
// v1.18.1 — close the forward-only TOCTOU: the in-memory guard above can
// pass concurrently in the cron poll AND the eventful worker for the same
// reading. Make the write itself conditional so exactly one wins. The
// `updateMany` filter re-asserts the forward-only invariant against the
// CURRENT row state; a racing writer that already advanced `lastSatisfiedAt`
// to >= satisfiedAt yields `count === 0`, which we treat as a no-op rather
// than re-stamping an older instant.
const result = await prisma.measurementReminder.updateMany({
where: {
id: reminder.id,
OR: [
{ lastSatisfiedAt: null },
{ lastSatisfiedAt: { lt: satisfiedAt } },
],
},
data: { lastSatisfiedAt: satisfiedAt, nextDueAt },
});
if (result.count === 0) {
return { satisfied: false, nextDueAt: null };
}
return { satisfied: true, nextDueAt };
}
+34 -1
View File
@@ -55,6 +55,34 @@ function hourToHhmm(hour: number): string {
return `${safe.toString().padStart(2, "0")}:00`;
}
/**
* v1.18.1 — pull the explicit clock hours out of an RRULE `BYHOUR` part so
* a multi-time-of-day protocol (the ESH/AHA "BP morning + evening" cadence,
* `FREQ=DAILY;BYHOUR=7,19`) fires once PER hour, not once a day.
*
* The recurrence engine expands `timesOfDay` on each occurrence day. The
* RRULE day-anchor walk on its own lands one slot per day; without lifting
* `BYHOUR` into `timesOfDay` the engine only ever fires at the single
* `notifyHour`, silently collapsing a twice-daily protocol to once. This
* keeps the label, the RRULE, and the engine output aligned.
*
* Returns the sorted, de-duplicated, in-range (0–23) `"HH:00"` strings, or
* `null` when the rrule carries no usable `BYHOUR` (the caller then falls
* back to the single `notifyHour`).
*/
export function byHourTimesOfDay(rrule: string | null): string[] | null {
if (!rrule) return null;
const match = /(?:^|;)BYHOUR=([0-9,]+)(?:;|$)/i.exec(rrule);
if (!match) return null;
const hours = match[1]
.split(",")
.map((part) => Number(part.trim()))
.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23);
if (hours.length === 0) return null;
const unique = Array.from(new Set(hours)).sort((a, b) => a - b);
return unique.map((h) => `${h.toString().padStart(2, "0")}:00`);
}
/**
* Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine
* consumes for one Vorsorge reminder.
@@ -69,12 +97,17 @@ export function buildReminderRecurrence(
timeZone: string,
): { schedule: CanonicalSchedule; ctx: RecurrenceContext } {
const hhmm = hourToHhmm(reminder.notifyHour);
// v1.18.1 — when the RRULE pins explicit BYHOUR clock hours (e.g. the
// twice-daily BP protocol `FREQ=DAILY;BYHOUR=7,19`), expand them into the
// engine's per-day timesOfDay so every hour fires; otherwise the cadence
// rides the single notifyHour. Aligns label + RRULE + engine output.
const timesOfDay = byHourTimesOfDay(reminder.rrule) ?? [hhmm];
const schedule: CanonicalSchedule = {
id: "measurement-reminder",
rrule: reminder.rrule,
rollingIntervalDays: reminder.intervalDays,
timesOfDay: [hhmm],
timesOfDay,
daysOfWeek: null,
windowStart: hhmm,
windowEnd: hhmm,
+86
View File
@@ -16,8 +16,94 @@ import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
// The action endpoint behind the one-tap reminder-suggestion card. The
// client sends ONLY the cadence id + the action; the server resolves the
// metric + schedule + course window from the closed catalog and (for
// `accept`) mints a `MeasurementReminder` with `origin: COACH`.
coachReminderSuggestionActionSchema.meta({
id: "CoachReminderSuggestionAction",
description:
"v1.18.1 — act on a Coach cadence suggestion. `cadenceId` names a closed-catalog preset (e.g. `weight_daily`, `bp_7_2_2`); the client never sends a schedule. `action`: `accept` creates a `MeasurementReminder` (origin: COACH) through the same engine the Vorsorge surface uses; `dismiss` records dismissal memory; `stop` suppresses all future cadence suggestions. Strict: unknown keys 422.",
});
const coachReminderSuggestionResultSchema = z
.object({
ok: z.literal(true),
action: z.enum(["accept", "dismiss", "stop"]),
reminder: z
.unknown()
.nullable()
.optional()
.describe(
"On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`).",
),
duplicate: z
.boolean()
.optional()
.describe(
"True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op).",
),
})
.meta({
id: "CoachReminderSuggestionResult",
description:
"Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 + `duplicate: true` when one already exists); `dismiss`/`stop` return 200.",
});
export const coachReminderSuggestionPaths: NonNullable<
ZodOpenApiObject["paths"]
> = {
"/api/coach/reminder-suggestions": {
post: {
tags: ["Insights"],
summary: "Act on a Coach cadence suggestion",
description:
"v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated (`requireModuleEnabled(\"coach\")`); a disabled surface 403s. `accept` resolves the cadence from the closed server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth via cookie or Bearer; the owner is narrowed from the session.",
requestBody: {
required: true,
content: {
"application/json": { schema: coachReminderSuggestionActionSchema },
},
},
responses: {
"200": {
description:
"A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`).",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderSuggestionResultSchema,
"CoachReminderSuggestionResultOk",
),
},
},
},
"201": {
description: "An accept that created a new reminder.",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderSuggestionResultSchema,
"CoachReminderSuggestionResultCreated",
),
},
},
},
"403": {
description: "Coach surface (or the cadence's module) disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
// 401 / 422 / 429 come from stdResponses.
...stdResponses,
},
},
},
};
const coachMessageFeedbackBody = z
.object({
rating: z.enum(["helpful", "unhelpful"]),
+6 -1
View File
@@ -23,7 +23,11 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { authPaths } from "./auth";
import { coachFeedbackPaths, coachPaths } from "./coach";
import {
coachFeedbackPaths,
coachPaths,
coachReminderSuggestionPaths,
} from "./coach";
import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
@@ -55,6 +59,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...medicationPaths,
...profilePaths,
...coachPaths,
...coachReminderSuggestionPaths,
...adminInvitePaths,
...coachFeedbackPaths,
...adminDiagnosticPaths,