mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol, structured glucose). The server resolves the cadence from a closed catalog and emits an additive 'suggestion' SSE frame the chat renders as a one-tap action card; accepting it creates a MeasurementReminder with origin COACH through the same engine the Vorsorge surface uses (server-authoritative schedule, finite course window self-expires via endsOn). Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a master opt-out under Coach settings, and an explicit 'I measure enough' stop path, all in the coach-prefs blob. Triple dedup at suggest, render, and create so a cadence is never double-suggested or double-created, and suggestions for a disabled module never surface. RHR/HRV stay passive -- the Coach surfaces the baseline, it never nudges to 'go measure' them. The reminder DTO now echoes origin and endsOn for the iOS client.
This commit is contained in:
1 parent
b6796eee96
commit
12f4bc24e8
29 files changed
+1538
-9
No files matched your search
@@ -171,4 +171,35 @@ describe("parseCoachPrefs", () => {
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
// v1.18.1 (Workstream C) — reminder-suggestion sub-shape.
|
||||
it("a legacy blob (no reminderSuggestions) parses with the key absent", () => {
|
||||
const out = parseCoachPrefs({ tone: "warm" });
|
||||
expect(out.reminderSuggestions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fills the reminderSuggestions defaults when the key is an empty object", () => {
|
||||
const out = coachPrefsSchema.parse({ reminderSuggestions: {} });
|
||||
expect(out.reminderSuggestions).toEqual({
|
||||
enabled: true,
|
||||
stopped: false,
|
||||
dismissedCadences: [],
|
||||
lastSuggestedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a populated reminderSuggestions block", () => {
|
||||
const out = coachPrefsSchema.parse({
|
||||
reminderSuggestions: {
|
||||
enabled: true,
|
||||
stopped: false,
|
||||
dismissedCadences: ["bp_7_2_2"],
|
||||
lastSuggestedAt: "2026-06-16T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(out.reminderSuggestions?.dismissedCadences).toEqual(["bp_7_2_2"]);
|
||||
expect(out.reminderSuggestions?.lastSuggestedAt).toBe(
|
||||
"2026-06-16T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,43 @@ export const DEFAULT_COACH_CLUSTERS: ReadonlyArray<CoachDataCluster> = [
|
||||
"medication",
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — Coach cadence-suggestion state. Persisted
|
||||
* inside the same `coachPrefsJson` blob so the suggestion engine has a
|
||||
* single, atomic place for the non-naggy machinery:
|
||||
*
|
||||
* - `enabled`: master opt-out for cadence suggestions (default ON; the
|
||||
* toggle lives under Coach / Reminders settings).
|
||||
* - `stopped`: the explicit "you measure enough — stop" path. Once the
|
||||
* user picks it the Coach never suggests a cadence again until they
|
||||
* re-enable. Distinct from `enabled:false` only in provenance (the user
|
||||
* said "stop" from the card vs flipped the settings toggle); both
|
||||
* suppress.
|
||||
* - `dismissedCadences`: cadence ids the user dismissed. A dismissed
|
||||
* cadence is never re-suggested (dismissal memory).
|
||||
* - `lastSuggestedAt`: ISO instant of the last suggestion actually shown.
|
||||
* Drives the cooldown (no second suggestion within the cooldown window).
|
||||
*
|
||||
* The shape is fully optional + defaulted so every legacy blob parses as
|
||||
* "suggestions on, nothing dismissed".
|
||||
*/
|
||||
export const coachReminderSuggestionPrefsSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
stopped: z.boolean().default(false),
|
||||
dismissedCadences: z.array(z.string().max(64)).max(32).default([]),
|
||||
lastSuggestedAt: z.string().max(40).nullable().default(null),
|
||||
});
|
||||
export type CoachReminderSuggestionPrefs = z.infer<
|
||||
typeof coachReminderSuggestionPrefsSchema
|
||||
>;
|
||||
|
||||
export const DEFAULT_REMINDER_SUGGESTION_PREFS: CoachReminderSuggestionPrefs = {
|
||||
enabled: true,
|
||||
stopped: false,
|
||||
dismissedCadences: [],
|
||||
lastSuggestedAt: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Full preferences shape. Defaults are inlined into the schema so a
|
||||
* `safeParse({})` call returns the legacy v1.4.22 defaults — saves a
|
||||
@@ -144,6 +181,12 @@ export const coachPrefsSchema = z.object({
|
||||
// — an empty array means "the user turned everything off", which is a
|
||||
// distinct, valid state from "never picked".
|
||||
dataClusters: z.array(coachDataClusterEnum).max(10).optional(),
|
||||
// v1.18.1 (Workstream C) — cadence-suggestion state. Optional with NO
|
||||
// top-level default so a legacy blob (and `parse({})`) stays byte-
|
||||
// identical: an absent key reads as `undefined`, and call sites fall
|
||||
// back to `DEFAULT_REMINDER_SUGGESTION_PREFS`. When the key IS present,
|
||||
// its inner fields default (so `{}` fills to the all-on shape).
|
||||
reminderSuggestions: coachReminderSuggestionPrefsSchema.optional(),
|
||||
});
|
||||
|
||||
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — request body for the Coach cadence-suggestion
|
||||
* action endpoint (`POST /api/coach/reminder-suggestions`).
|
||||
*
|
||||
* The client sends ONLY the cadence id and the action it took; the server
|
||||
* resolves the actual cadence (metric + schedule + course window) from the
|
||||
* closed catalog, so the client can never widen a cadence or inject a
|
||||
* schedule.
|
||||
*
|
||||
* - `accept` → create a `MeasurementReminder` with `origin: COACH`.
|
||||
* - `dismiss` → record dismissal memory (never re-suggest this cadence).
|
||||
* - `stop` → the explicit "you measure enough — stop" path (suppress
|
||||
* every future cadence suggestion until re-enabled).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const coachReminderSuggestionActionSchema = z.object({
|
||||
cadenceId: z.string().trim().min(1).max(64),
|
||||
action: z.enum(["accept", "dismiss", "stop"]),
|
||||
});
|
||||
|
||||
export type CoachReminderSuggestionAction = z.infer<
|
||||
typeof coachReminderSuggestionActionSchema
|
||||
>;
|
||||
@@ -169,6 +169,8 @@ export const measurementReminderDto = z
|
||||
intervalDays: z.number().int().nullable(),
|
||||
rrule: z.string().nullable(),
|
||||
anchorDate: z.iso.datetime({ offset: true }).nullable(),
|
||||
endsOn: z.iso.datetime({ offset: true }).nullable(),
|
||||
origin: z.enum(["VORSORGE", "COACH"]),
|
||||
notifyHour: z.number().int(),
|
||||
location: z.string().nullable(),
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
@@ -180,5 +182,5 @@ export const measurementReminderDto = z
|
||||
.meta({
|
||||
id: "MeasurementReminderDTO",
|
||||
description:
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.",
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
|
||||
});
|
||||
Reference in new issue
Block a user