mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
b28fa4d4a5
commit
7451e886f1
32 files changed
+983
-230
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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---";
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user