mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone, verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in the Coach settings sheet; the drawer header carries a small pill that overrides the saved default for the current chat only. Closing the drawer drops the override so the next session starts on the saved preference. Backwards-compatible: legacy rows missing the key resolve to "allTime", matching the v1.4.24 behaviour. The chat route folds the saved window into snapshot scope when the client doesn't supply one.
This commit is contained in:
1 parent
2bd29022b1
commit
330be965ac
10 files changed
+331
-8
No files matched your search
@@ -206,12 +206,22 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// the new user message. v1.4.23 H4 — fold per-user prefs into the
|
||||
// system-prompt prefix; the snapshot builder reads the same prefs
|
||||
// separately so excluded metrics never even leave the DB.
|
||||
//
|
||||
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
|
||||
// analysis-window preference. Merge it into the snapshot scope when
|
||||
// the client didn't supply a per-conversation override; the override
|
||||
// (header pill / sources rail) always wins. Keep the merge cheap so
|
||||
// we don't accidentally widen narrow per-call scopes.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { coachPrefsJson: true },
|
||||
});
|
||||
const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
const snapshot = await buildCoachSnapshot(userId, scope);
|
||||
const effectiveScope =
|
||||
scope?.window === undefined && coachPrefs.defaultWindow
|
||||
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
|
||||
: scope;
|
||||
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
|
||||
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
|
||||
const window = buildHistoryWindow([
|
||||
...priorTurns,
|
||||
|
||||
@@ -104,4 +104,14 @@ describe("<CoachSettingsSheet>", () => {
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved.");
|
||||
});
|
||||
|
||||
// v1.4.25 W5 — defaultWindow picker added.
|
||||
it("renders the default-window picker once persisted prefs land", () => {
|
||||
queryState.data = DEFAULT_COACH_PREFS;
|
||||
const html = render(
|
||||
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-prefs-default-window"');
|
||||
expect(html).toContain("Default analysis window");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,13 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { Plus, Settings, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -14,6 +21,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
import { CoachInput } from "./coach-input";
|
||||
@@ -153,6 +161,13 @@ export function CoachDrawer({
|
||||
// selector). Resets to the all-source last30days default each time
|
||||
// the drawer mounts; no conversation-level persistence in this
|
||||
// hotfix per the v1.4.20.1 plan.
|
||||
//
|
||||
// v1.4.25 W5 — the scope.window field is now a two-layer override:
|
||||
// • the user's saved `coachPrefs.defaultWindow` is the base
|
||||
// • the header pill drops a per-conversation override into
|
||||
// `windowOverride`; null = "use the saved default"
|
||||
// The override resets to null on drawer close so the next session
|
||||
// starts on the user's saved preference.
|
||||
const [scope, setScope] = useState<{
|
||||
sources: CoachScopeSource[];
|
||||
window: CoachScopeWindow;
|
||||
@@ -160,15 +175,31 @@ export function CoachDrawer({
|
||||
sources: [...DEFAULT_COACH_SCOPE.sources],
|
||||
window: DEFAULT_COACH_SCOPE.window,
|
||||
}));
|
||||
const [windowOverride, setWindowOverride] =
|
||||
useState<CoachScopeWindow | null>(null);
|
||||
|
||||
// v1.4.25 W5 — load the user's saved Coach prefs so the default
|
||||
// window picks up the cog's saved selection. The hook gates the
|
||||
// fetch on `enabled` so the network call only fires while the
|
||||
// drawer is open. Falls through to the legacy "last30days" default
|
||||
// when the row is missing or the request is in flight.
|
||||
const { data: coachPrefs } = useCoachPrefs({ enabled: open });
|
||||
const savedDefaultWindow: CoachScopeWindow =
|
||||
coachPrefs?.defaultWindow ?? DEFAULT_COACH_SCOPE.window;
|
||||
const effectiveWindow: CoachScopeWindow =
|
||||
windowOverride ?? savedDefaultWindow;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
// Reset thread + composer on close so the next open starts on
|
||||
// the rail's empty hint instead of re-rendering the previous
|
||||
// conversation by accident.
|
||||
// conversation by accident. v1.4.25 W5 — also drop the
|
||||
// per-conversation window override so the next session starts
|
||||
// on the user's saved `coachPrefs.defaultWindow` again.
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
setWindowOverride(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
@@ -189,15 +220,21 @@ export function CoachDrawer({
|
||||
// Pass the scope only when the user has narrowed it from the
|
||||
// defaults — keeps the wire payload minimal and lets the route
|
||||
// tell "no opinion" apart from "intentionally narrow".
|
||||
const isDefault =
|
||||
scope.window === DEFAULT_COACH_SCOPE.window &&
|
||||
//
|
||||
// v1.4.25 W5 — `scope.window` now reflects the per-conversation
|
||||
// override (header pill / rail picker) layered on top of the saved
|
||||
// `coachPrefs.defaultWindow`. Send the override only when the user
|
||||
// explicitly diverged from the saved default; otherwise the route
|
||||
// re-applies the saved preference itself.
|
||||
const allSourcesSelected =
|
||||
scope.sources.length === DEFAULT_COACH_SCOPE.sources.length &&
|
||||
DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s));
|
||||
const isDefault = allSourcesSelected && windowOverride === null;
|
||||
const scopePayload: CoachScope | undefined = isDefault
|
||||
? undefined
|
||||
: {
|
||||
sources: scope.sources,
|
||||
window: scope.window,
|
||||
window: effectiveWindow,
|
||||
};
|
||||
await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
@@ -256,6 +293,49 @@ export function CoachDrawer({
|
||||
{t("insights.coach.tagline")}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
{/* v1.4.25 W5 — per-conversation window override. The pill
|
||||
defaults to the user's saved `coachPrefs.defaultWindow`
|
||||
and resets to it on drawer close. Changing the pill flips
|
||||
`windowOverride`; the rail's window picker mirrors the
|
||||
same source-of-truth so the user can drive the override
|
||||
from either surface. */}
|
||||
<Select
|
||||
value={effectiveWindow}
|
||||
onValueChange={(value) => {
|
||||
const next = value as CoachScopeWindow;
|
||||
setWindowOverride(next === savedDefaultWindow ? null : next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
data-slot="coach-drawer-window-pill"
|
||||
aria-label={t("insights.coach.windowLabel")}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground h-7 shrink-0 gap-1 rounded-full px-2.5 text-[11px]",
|
||||
"hover:bg-muted/60 focus-visible:ring-ring/40 focus-visible:ring-2",
|
||||
windowOverride !== null &&
|
||||
"border-dracula-purple/40 bg-dracula-purple/10 text-dracula-purple",
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(`insights.coach.window.${effectiveWindow}`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="last7days" className="text-xs">
|
||||
{t("insights.coach.window.last7days")}
|
||||
</SelectItem>
|
||||
<SelectItem value="last30days" className="text-xs">
|
||||
{t("insights.coach.window.last30days")}
|
||||
</SelectItem>
|
||||
<SelectItem value="last90days" className="text-xs">
|
||||
{t("insights.coach.window.last90days")}
|
||||
</SelectItem>
|
||||
<SelectItem value="allTime" className="text-xs">
|
||||
{t("insights.coach.window.allTime")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -297,7 +377,20 @@ export function CoachDrawer({
|
||||
}
|
||||
sourcesRail={
|
||||
sourcesRail ?? (
|
||||
<SourcesRail scope={scope} onScopeChange={setScope} />
|
||||
<SourcesRail
|
||||
// v1.4.25 W5 — overlay the effective window on the rail
|
||||
// so the picker mirrors the per-conversation override
|
||||
// (or the saved default when no override is active).
|
||||
// The rail's own onChange flips `windowOverride` so the
|
||||
// rail picker is just another way to set the override.
|
||||
scope={{ sources: scope.sources, window: effectiveWindow }}
|
||||
onScopeChange={(next) => {
|
||||
setScope((prev) => ({ ...prev, sources: next.sources }));
|
||||
setWindowOverride(
|
||||
next.window === savedDefaultWindow ? null : next.window,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
thread={
|
||||
@@ -374,7 +467,18 @@ export function CoachDrawer({
|
||||
</SheetHeader>
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
{sourcesRail ?? (
|
||||
<SourcesRail scope={scope} onScopeChange={setScope} />
|
||||
<SourcesRail
|
||||
scope={{
|
||||
sources: scope.sources,
|
||||
window: effectiveWindow,
|
||||
}}
|
||||
onScopeChange={(next) => {
|
||||
setScope((prev) => ({ ...prev, sources: next.sources }));
|
||||
setWindowOverride(
|
||||
next.window === savedDefaultWindow ? null : next.window,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_PREFS,
|
||||
type CoachDefaultWindow,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
@@ -71,6 +72,16 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
|
||||
"steps",
|
||||
];
|
||||
|
||||
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
|
||||
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
|
||||
// straight into `scope.window` when the client didn't supply one.
|
||||
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
export function CoachSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -237,6 +248,46 @@ export function CoachSettingsSheet({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
|
||||
scope window the Coach reads when the client doesn't
|
||||
supply a per-conversation override. The drawer header
|
||||
carries a small pill that overrides the picked default
|
||||
for the current chat only. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-default-window"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsDefaultWindowLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsDefaultWindowHint")}
|
||||
</p>
|
||||
<Select
|
||||
value={draft.defaultWindow}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
defaultWindow: value as CoachDefaultWindow,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-default-window"
|
||||
data-slot="coach-prefs-default-window"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_WINDOW_OPTIONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`insights.coach.window.${w}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Exclude metrics */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
|
||||
@@ -86,6 +86,11 @@ const METRIC_ICON: Record<
|
||||
distance: Route,
|
||||
vo2_max: Zap,
|
||||
body_temp: Thermometer,
|
||||
// ── v1.4.25 W4d GLP-1 additive ──
|
||||
// Plateau finding inherits the Pill icon since it's an
|
||||
// adherence-context observation about the user's GLP-1 therapy.
|
||||
// Non-GLP-1 accounts never see this finding type.
|
||||
glp1_plateau: Pill,
|
||||
};
|
||||
|
||||
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
|
||||
|
||||
@@ -96,6 +96,31 @@ GROUND RULES
|
||||
last90days / allTime) — the ban applies to the metric label
|
||||
itself, not to the contract-level "window" token.
|
||||
|
||||
9. NEVER prescribe, recommend, or modify medication doses
|
||||
(v1.4.25 W4d). The SNAPSHOT may carry a "weeklyContext.glp1"
|
||||
block that names the user's GLP-1 receptor agonist (Mounjaro,
|
||||
Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus), the
|
||||
current dose, the titration history, the injection cadence, the
|
||||
last + next injection date, the side-effect tag counts, and the
|
||||
pen inventory. Use this block to GROUND your reply — say
|
||||
"your Mounjaro 7.5 mg" instead of "your medication", and
|
||||
reference "the typical Eli Lilly titration schedule waits at
|
||||
least 4 weeks per step" when the user asks how titration
|
||||
normally works.
|
||||
But: when the user asks "should I increase my dose?", "is it
|
||||
time to step up?", "can I skip a dose?", "should I stop?", or
|
||||
any variation on dose recommendation, defer to their
|
||||
prescribing clinician in one short sentence and offer to think
|
||||
through the timing question with them. NEVER recommend a
|
||||
specific value. NEVER tell them to take an action with their
|
||||
medication. Pattern: "Dose changes are a conversation for your
|
||||
prescribing doctor — they know the rest of your picture. If
|
||||
it's useful, I can pull up how the last few weeks have looked
|
||||
so you can take that into the appointment."
|
||||
This is a SAFETY contract, not a stylistic preference. If you
|
||||
are unsure whether a question is dose-prescriptive, treat it as
|
||||
if it is and defer to the clinician.
|
||||
|
||||
DAY-LEVEL READINGS — USE THE TIMELINE
|
||||
|
||||
Each metric in the SNAPSHOT carries a "timeline" object alongside
|
||||
@@ -285,6 +310,33 @@ GRUNDREGELN
|
||||
allTime) — das Verbot gilt der Metrik-Bezeichnung selbst, nicht
|
||||
dem Vertragstoken im "window"-Slot.
|
||||
|
||||
9. Du verschreibst, empfiehlst und veränderst NIEMALS Medikamenten-
|
||||
Dosen (v1.4.25 W4d). Der SNAPSHOT kann einen Block
|
||||
"weeklyContext.glp1" mitbringen, der den GLP-1-Rezeptoragonisten
|
||||
des Nutzers benennt (Mounjaro, Ozempic, Wegovy, Zepbound,
|
||||
Trulicity, Saxenda, Rybelsus), die aktuelle Dosis, die
|
||||
Titrationshistorie, die Injektionsfrequenz, das letzte und
|
||||
nächste Injektionsdatum, die Nebenwirkungs-Tag-Zählungen und
|
||||
den Pen-Bestand. Nutze diesen Block, um deine Antwort zu
|
||||
ERDEN — sag "dein Mounjaro 7,5 mg" statt "deine Medikation",
|
||||
und verweise auf "Eli Lillys publizierter Titrationsplan sieht
|
||||
typischerweise mindestens 4 Wochen pro Stufe vor", wenn der
|
||||
Nutzer fragt, wie Titration üblicherweise abläuft.
|
||||
Aber: Wenn der Nutzer fragt "Soll ich meine Dosis erhöhen?",
|
||||
"Ist es Zeit für die nächste Stufe?", "Kann ich eine Dosis
|
||||
auslassen?", "Soll ich aufhören?" oder eine Variante davon
|
||||
stellt, verweise in einem kurzen Satz an die behandelnde Ärztin
|
||||
bzw. den Arzt und biete an, die Frage gemeinsam für den
|
||||
nächsten Termin vorzubereiten. Empfehle NIE einen konkreten
|
||||
Wert. Sag NIE, sie sollen etwas an ihrer Medikation ändern.
|
||||
Muster: "Dosis-Anpassungen gehören in das Gespräch mit deiner
|
||||
behandelnden Ärztin — sie kennt das Gesamtbild. Wenn es hilft,
|
||||
kann ich die letzten Wochen mit dir kurz durchgehen, damit du
|
||||
das gut vorbereitet in den Termin nimmst."
|
||||
Das ist ein SICHERHEITS-Vertrag, kein Stil-Wunsch. Bist du
|
||||
unsicher, ob eine Frage dosis-präskriptiv ist, behandle sie so —
|
||||
und verweise an die Klinik.
|
||||
|
||||
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
|
||||
|
||||
Jede Metrik im SNAPSHOT trägt neben dem "aggregate"-Block ein
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "../medical-references";
|
||||
|
||||
/** Stable identifier for the active system prompt revision. */
|
||||
export const PROMPT_VERSION = "4.24.0" as const;
|
||||
export const PROMPT_VERSION = "4.25.0" as const;
|
||||
|
||||
const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app.
|
||||
Prompt version: ${PROMPT_VERSION}.
|
||||
@@ -210,6 +210,21 @@ GROUND RULES — ZERO HALLUCINATIONS
|
||||
contract-level identifiers the parser reads — those stay in
|
||||
the documented enum vocabulary exactly as listed in OUTPUT
|
||||
FORMAT below. The ban applies ONLY to prose.
|
||||
14. v1.4.25 W4d — NEVER prescribe or modify medication doses, even
|
||||
when the snapshot reveals a named GLP-1 receptor agonist
|
||||
(Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda,
|
||||
Rybelsus). Findings may NOTE the named medication and the
|
||||
user's current titration step ("week 3 on 7.5 mg") when the
|
||||
"weeklyContext.glp1" block carries it, but recommendations and
|
||||
summary text must NEVER read "you should step up to X mg",
|
||||
"consider increasing to Y mg", "stop at Z mg", or any
|
||||
variation. A plateau finding ALWAYS frames the next decision
|
||||
as a conversation with the prescribing clinician — pattern:
|
||||
"Weight has settled around <kg> for three weeks at <dose> —
|
||||
typical mid-titration. Worth mentioning at the next visit if
|
||||
it persists." This is a SAFETY contract; treat any
|
||||
dose-prescriptive instinct as a sign the response is
|
||||
out-of-bounds.
|
||||
|
||||
GUIDELINE TARGETS — generic, do NOT compute precise risk scores
|
||||
- Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90
|
||||
@@ -527,6 +542,23 @@ GRUNDREGELN — NULL HALLUZINATIONEN
|
||||
Parser liest — diese bleiben EXAKT in der unten in
|
||||
AUSGABEFORMAT dokumentierten Enum-Schreibweise. Das Verbot
|
||||
gilt AUSSCHLIEßLICH für Fließtext.
|
||||
14. v1.4.25 W4d — Du verschreibst und änderst NIEMALS
|
||||
Medikamenten-Dosen, auch wenn der Snapshot einen
|
||||
GLP-1-Rezeptoragonisten namentlich benennt (Mounjaro, Ozempic,
|
||||
Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Befunde dürfen
|
||||
den benannten Wirkstoff und die aktuelle Titrationsstufe
|
||||
ZITIEREN ("Woche 3 auf 7,5 mg"), wenn der Block
|
||||
"weeklyContext.glp1" diese Informationen mitbringt — aber
|
||||
Empfehlungen und summary-Text dürfen NIEMALS "du solltest auf
|
||||
X mg erhöhen", "erwäge die nächste Stufe Y mg", "bleibe auf Z
|
||||
mg" oder eine Variante davon enthalten. Ein Plateau-Befund
|
||||
rahmt die nächste Entscheidung IMMER als Gespräch mit der
|
||||
behandelnden Ärztin — Muster: "Das Gewicht hat sich seit drei
|
||||
Wochen bei <kg> auf <Dosis> eingependelt — typische
|
||||
mid-titration Phase. Lohnt sich beim nächsten Termin
|
||||
anzusprechen, falls es darüber hinaus persistiert." Das ist
|
||||
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
|
||||
Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.
|
||||
|
||||
LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
|
||||
- Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90
|
||||
|
||||
@@ -282,6 +282,13 @@ export const dailyBriefingKeyFindingSchema = z.object({
|
||||
"distance",
|
||||
"vo2_max",
|
||||
"body_temp",
|
||||
// ── v1.4.25 W4d GLP-1 additive ──
|
||||
// Findings emitted by the plateau-detection rule when a user has
|
||||
// an active GLP-1 receptor agonist and weight has flattened on a
|
||||
// stable dose for ≥21 days. The finding ALWAYS defers to the
|
||||
// user's clinician (no dose recommendation); the enum value
|
||||
// exists so the UI can pin the right icon + filter.
|
||||
"glp1_plateau",
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
@@ -21,11 +21,24 @@ describe("coachPrefsSchema", () => {
|
||||
verbosity: "brief" as const,
|
||||
excludeMetrics: ["bp", "weight"] as const,
|
||||
showEvidenceByDefault: true,
|
||||
defaultWindow: "last30days" as const,
|
||||
};
|
||||
const out = coachPrefsSchema.parse(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
// v1.4.25 W5 — defaultWindow added. Missing key → fallback to
|
||||
// "allTime" so the legacy persisted shape stays representative.
|
||||
it("fills in defaultWindow=allTime when the key is missing", () => {
|
||||
const out = coachPrefsSchema.parse({});
|
||||
expect(out.defaultWindow).toBe("allTime");
|
||||
});
|
||||
|
||||
it("rejects unknown defaultWindow values", () => {
|
||||
const result = coachPrefsSchema.safeParse({ defaultWindow: "lifetime" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown tone values", () => {
|
||||
const result = coachPrefsSchema.safeParse({ tone: "stoic" });
|
||||
expect(result.success).toBe(false);
|
||||
@@ -82,7 +95,21 @@ describe("parseCoachPrefs", () => {
|
||||
verbosity: "default" as const,
|
||||
excludeMetrics: ["mood" as const],
|
||||
showEvidenceByDefault: true,
|
||||
defaultWindow: "last90days" as const,
|
||||
};
|
||||
expect(parseCoachPrefs(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("fills defaultWindow=allTime when older persisted rows are missing it", () => {
|
||||
// Legacy v1.4.23/v1.4.24 persisted shape without `defaultWindow`.
|
||||
// Defaulting is backwards-compatible — the row keeps reading as
|
||||
// "all time" until the user picks a tighter default in the cog.
|
||||
const legacy = {
|
||||
tone: "warm" as const,
|
||||
verbosity: "default" as const,
|
||||
excludeMetrics: [] as const,
|
||||
showEvidenceByDefault: false,
|
||||
};
|
||||
expect(parseCoachPrefs(legacy).defaultWindow).toBe("allTime");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@
|
||||
* snapshot builder both read this shape — the snapshot must filter on
|
||||
* `excludeMetrics` BEFORE landing in the system prompt so the model
|
||||
* never sees data the user opted out of.
|
||||
*
|
||||
* v1.4.25 W5 — extended with `defaultWindow`. The picker lives in the
|
||||
* settings sheet; the drawer header carries a per-conversation override
|
||||
* pill so a single chat can narrow the window without flipping the
|
||||
* global default. The chat route folds the saved preference into the
|
||||
* snapshot scope when the client didn't supply a window override.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -52,6 +58,23 @@ export const coachExcludeMetricEnum = z.enum([
|
||||
]);
|
||||
export type CoachExcludeMetric = z.infer<typeof coachExcludeMetricEnum>;
|
||||
|
||||
/**
|
||||
* v1.4.25 W5 — default analysis window the Coach uses when the client
|
||||
* doesn't supply a per-conversation override. Mirrors
|
||||
* `CoachScopeWindow` (`src/lib/ai/coach/types.ts`) so the chat route
|
||||
* can fold the preference into `scope.window` without a translation
|
||||
* layer. The default stays `allTime` to preserve the v1.4.24 behaviour
|
||||
* — every legacy row reads as "no opinion" until the user explicitly
|
||||
* picks a tighter default.
|
||||
*/
|
||||
export const coachDefaultWindowEnum = z.enum([
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
]);
|
||||
export type CoachDefaultWindow = z.infer<typeof coachDefaultWindowEnum>;
|
||||
|
||||
/**
|
||||
* Full preferences shape. Defaults are inlined into the schema so a
|
||||
* `safeParse({})` call returns the legacy v1.4.22 defaults — saves a
|
||||
@@ -62,6 +85,7 @@ export const coachPrefsSchema = z.object({
|
||||
verbosity: coachVerbosityEnum.default("default"),
|
||||
excludeMetrics: z.array(coachExcludeMetricEnum).max(9).default([]),
|
||||
showEvidenceByDefault: z.boolean().default(false),
|
||||
defaultWindow: coachDefaultWindowEnum.default("allTime"),
|
||||
});
|
||||
|
||||
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
|
||||
@@ -77,6 +101,7 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = {
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
showEvidenceByDefault: false,
|
||||
defaultWindow: "allTime",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user