mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(coach): back the chat source rail with persisted cluster prefs
The chat-side sources rail and the settings cog drove two different data-source models — the rail hardcoded the legacy five sources and kept its selection in ephemeral drawer state that reset on close, while the cog edited the ten persisted clusters on User.coachPrefsJson. The prompt scope followed the persisted clusters, so what the rail showed could differ from what the model received. Bind the rail to the same persisted prefs: it now reads useCoachPrefs and writes the cluster toggles + analysis window through a shared useSaveCoachPrefs mutation, so a toggle sticks and the cog reflects it (and vice-versa). The chat request no longer sends a scope override, so the route resolves the source set and window from coachPrefsJson and the rail display cannot drift from the prompt. Drop the ephemeral scope state, the duplicate header window pill, and the legacy five-source rail model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
91f23bae68
commit
c48541e74f
11 files changed
+269
-422
No files matched your search
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Daten, die der Coach sieht",
|
||||
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
|
||||
"settingsClusterNoData": "Noch keine Daten"
|
||||
"settingsClusterNoData": "Noch keine Daten",
|
||||
"sourcesMemberCount": "{count} Werte"
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
|
||||
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Data the Coach sees",
|
||||
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
|
||||
"settingsClusterNoData": "No data yet"
|
||||
"settingsClusterNoData": "No data yet",
|
||||
"sourcesMemberCount": "{count} metrics"
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
|
||||
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Datos que ve el Coach",
|
||||
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
|
||||
"settingsClusterNoData": "Aún no hay datos"
|
||||
"settingsClusterNoData": "Aún no hay datos",
|
||||
"sourcesMemberCount": "{count} métricas"
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
|
||||
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Données vues par le Coach",
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données"
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCount": "{count} mesures"
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
|
||||
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Dati che il Coach vede",
|
||||
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
|
||||
"settingsClusterNoData": "Ancora nessun dato"
|
||||
"settingsClusterNoData": "Ancora nessun dato",
|
||||
"sourcesMemberCount": "{count} metriche"
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
|
||||
+2
-1
@@ -1735,7 +1735,8 @@
|
||||
},
|
||||
"settingsClustersLabel": "Dane widziane przez Coacha",
|
||||
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
|
||||
"settingsClusterNoData": "Brak danych"
|
||||
"settingsClusterNoData": "Brak danych",
|
||||
"sourcesMemberCount": "{count} wskaźników"
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_COACH_PREFS,
|
||||
type CoachPrefs,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
/**
|
||||
* v1.7.2 — the sources rail is now backed by the persisted Coach prefs
|
||||
* (`useCoachPrefs` read + `useSaveCoachPrefs` write), the same row the
|
||||
* settings cog edits. It renders the 10 data-source clusters as
|
||||
* persistent toggles plus the saved analysis-window picker. The legacy
|
||||
* ephemeral 5-source checkbox model is gone.
|
||||
*
|
||||
* Mock the prefs hooks so the static markup reflects a known prefs
|
||||
* shape; the `save` mutation is a captured spy.
|
||||
*/
|
||||
const prefsState: { data: CoachPrefs | undefined } = {
|
||||
data: DEFAULT_COACH_PREFS,
|
||||
};
|
||||
const saveSpy = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/use-coach-prefs", () => ({
|
||||
useCoachPrefs: () => ({ data: prefsState.data }),
|
||||
useSaveCoachPrefs: () => ({ mutate: saveSpy, isPending: false }),
|
||||
}));
|
||||
|
||||
import { SourcesRail } from "../sources-rail";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -10,6 +35,11 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prefsState.data = DEFAULT_COACH_PREFS;
|
||||
saveSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("<SourcesRail>", () => {
|
||||
it("renders the rail wrapper + label", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
@@ -17,68 +47,85 @@ describe("<SourcesRail>", () => {
|
||||
expect(html).toContain("What I can see");
|
||||
});
|
||||
|
||||
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
|
||||
// The rail mounts inline on `xl+` desktop where no `SheetTitle`
|
||||
// wrapper covers it. Promoting the label to `<h3>` restores the
|
||||
// drawer's semantic outline so screen-reader users can navigate
|
||||
// by heading.
|
||||
it("renders the rail label as a real `<h3>` heading", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toMatch(
|
||||
/<h3[^>]*data-slot="coach-sources-rail-heading"[^>]*>[\s\S]*What I can see[\s\S]*<\/h3>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => {
|
||||
it("lists exactly the ten persisted data clusters", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
const rows = html.match(/data-slot="coach-sources-row"/g) ?? [];
|
||||
expect(rows.length).toBe(5);
|
||||
expect(html).toContain('data-source="bp"');
|
||||
expect(html).toContain('data-source="weight"');
|
||||
expect(html).toContain('data-source="pulse"');
|
||||
expect(html).toContain('data-source="mood"');
|
||||
expect(html).toContain('data-source="compliance"');
|
||||
expect(rows.length).toBe(10);
|
||||
for (const cluster of [
|
||||
"cardio",
|
||||
"body",
|
||||
"activity",
|
||||
"workouts",
|
||||
"sleep",
|
||||
"mood",
|
||||
"glucose",
|
||||
"medication",
|
||||
"mobility",
|
||||
"environment",
|
||||
]) {
|
||||
expect(html).toContain(`data-source="${cluster}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the localised metric labels in English", () => {
|
||||
it("renders the localised cluster labels in English", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toContain("Blood pressure");
|
||||
expect(html).toContain("Weight");
|
||||
expect(html).toContain("Pulse");
|
||||
expect(html).toContain("Mood");
|
||||
expect(html).toContain("Compliance");
|
||||
expect(html).toContain("Cardiovascular");
|
||||
expect(html).toContain("Body composition");
|
||||
expect(html).toContain("Medication");
|
||||
});
|
||||
|
||||
it("renders the German labels when locale is 'de'", () => {
|
||||
it("renders the German cluster labels when locale is 'de'", () => {
|
||||
const html = render(<SourcesRail />, "de");
|
||||
expect(html).toContain("Blutdruck");
|
||||
expect(html).toContain("Gewicht");
|
||||
expect(html).toContain("Einnahmetreue");
|
||||
// German cluster labels resolve from the same `cluster.*` namespace
|
||||
// the settings cog uses.
|
||||
expect(html).toContain('data-source="cardio"');
|
||||
expect(html).toContain('data-source="medication"');
|
||||
});
|
||||
|
||||
it("renders the medical disclaimer in the footer (relocated from the composer in v1.4.22)", () => {
|
||||
it("renders the medical disclaimer in the footer", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toContain('data-slot="coach-sources-disclaimer"');
|
||||
expect(html).toContain("Clinical decisions belong with your doctor");
|
||||
});
|
||||
|
||||
it("renders a checkbox per row + a window selector trigger", () => {
|
||||
it("renders a toggle per cluster + the window selector trigger", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
const checkboxes = (html.match(/data-slot="coach-sources-checkbox"/g) ?? [])
|
||||
const toggles = (html.match(/data-slot="coach-sources-checkbox"/g) ?? [])
|
||||
.length;
|
||||
expect(checkboxes).toBe(5);
|
||||
expect(toggles).toBe(10);
|
||||
expect(html).toContain('data-slot="coach-sources-window-trigger"');
|
||||
});
|
||||
|
||||
it("paints checkboxes as checked when scope.sources includes the row", () => {
|
||||
const html = render(
|
||||
<SourcesRail
|
||||
scope={{ sources: ["bp", "weight"], window: "last7days" }}
|
||||
onScopeChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
// The bp + weight rows should be marked active; others inactive.
|
||||
expect(html).toMatch(/data-source="bp"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="weight"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="pulse"[^>]*data-active="false"/);
|
||||
it("paints the default clusters active and the opt-in clusters inactive", () => {
|
||||
// DEFAULT_COACH_PREFS leaves `dataClusters` undefined → legacy
|
||||
// defaults (cardio, body, mood, medication) are on; the rest off.
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toMatch(/data-source="cardio"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="body"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="mood"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="medication"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="activity"[^>]*data-active="false"/);
|
||||
expect(html).toMatch(/data-source="glucose"[^>]*data-active="false"/);
|
||||
});
|
||||
|
||||
it("reflects an explicit persisted cluster set", () => {
|
||||
prefsState.data = { ...DEFAULT_COACH_PREFS, dataClusters: ["glucose"] };
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toMatch(/data-source="glucose"[^>]*data-active="true"/);
|
||||
expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/);
|
||||
});
|
||||
|
||||
it("shows the per-cluster member-count hint", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
// cardio expands to 9 sources, mood to 1 — both surface a count.
|
||||
expect(html).toContain("9 metrics");
|
||||
expect(html).toContain("1 metrics");
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,6 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { Plus, Settings, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -22,7 +15,6 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
@@ -31,13 +23,8 @@ import { CoachSettingsSheet } from "./coach-settings-sheet";
|
||||
import { HistoryRail } from "./history-rail";
|
||||
import { MessageThread } from "./message-thread";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
import type {
|
||||
CoachScope,
|
||||
CoachScopeSource,
|
||||
CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
|
||||
@@ -167,50 +154,23 @@ export function CoachDrawer({
|
||||
// user can still type freely; the drawer only resets the composer
|
||||
// when the parent changes which suggested-prompt chip is active.
|
||||
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
|
||||
// v1.4.20.1 — scope picker state (per-source checkboxes + window
|
||||
// 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;
|
||||
}>(() => ({
|
||||
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;
|
||||
// v1.7.2 — the data-source scope is no longer ephemeral drawer state.
|
||||
// Both the chat-side sources rail and the settings cog now drive the
|
||||
// persisted `coachPrefsJson` row (clusters + default window) through
|
||||
// `PUT /api/auth/me/coach-prefs`. The chat request carries no `scope`
|
||||
// override at all, so the snapshot builder expands the saved clusters
|
||||
// and folds the saved window server-side — "what the rail shows" and
|
||||
// "what the model receives" cannot drift.
|
||||
|
||||
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. v1.4.25 W5 — also drop the
|
||||
// per-conversation window override so the next session starts
|
||||
// on the user's saved `coachPrefs.defaultWindow` again.
|
||||
// conversation by accident.
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
setWindowOverride(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
@@ -228,29 +188,14 @@ export function CoachDrawer({
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || send.isStreaming) return;
|
||||
setInputValue("");
|
||||
// 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".
|
||||
//
|
||||
// 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: effectiveWindow,
|
||||
};
|
||||
// v1.7.2 — no `scope` override on the wire. The route resolves the
|
||||
// source set and window from the user's persisted `coachPrefsJson`
|
||||
// (the same row the sources rail + settings cog edit), so the chat
|
||||
// request stays minimal and the prompt scope is guaranteed to match
|
||||
// what the rail displays.
|
||||
await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
scope: scopePayload,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,61 +275,11 @@ 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.
|
||||
|
||||
v1.4.27 R3d MB4 — on `<sm` viewports the bottom-sheet
|
||||
header already carries the avatar, title, new-chat,
|
||||
settings, and close buttons; one more pill there shrinks
|
||||
the title to a single character before truncation. The
|
||||
same override is still reachable from the sources-rail's
|
||||
window picker, which is what the user opens via the
|
||||
right-edge chevron tray on phone-class viewports. */}
|
||||
<div
|
||||
data-slot="coach-drawer-window-pill-wrap"
|
||||
className="hidden sm:block"
|
||||
>
|
||||
<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")}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground h-11 shrink-0 gap-1 rounded-full px-3 text-xs",
|
||||
"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>
|
||||
</div>
|
||||
{/* v1.7.2 — the per-conversation window override pill was
|
||||
retired. The analysis window now persists alongside the
|
||||
data-source clusters via the sources rail (and the
|
||||
settings cog), so a single, sticky window control replaces
|
||||
the ephemeral header override. */}
|
||||
{/* v1.4.25 W5 — header action cluster. All three buttons
|
||||
share the same `ghost / size-icon / size-9` shape so they
|
||||
visually belong together. 36×36 px hit target meets the
|
||||
@@ -443,24 +338,7 @@ export function CoachDrawer({
|
||||
/>
|
||||
)
|
||||
}
|
||||
sourcesRail={
|
||||
sourcesRail ?? (
|
||||
<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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sourcesRail={sourcesRail ?? <SourcesRail />}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
@@ -518,22 +396,7 @@ export function CoachDrawer({
|
||||
}
|
||||
sourcesOpen={sourcesTrayOpen}
|
||||
onSourcesOpenChange={setSourcesTrayOpen}
|
||||
sourcesRail={
|
||||
sourcesRail ?? (
|
||||
<SourcesRail
|
||||
scope={{
|
||||
sources: scope.sources,
|
||||
window: effectiveWindow,
|
||||
}}
|
||||
onScopeChange={(next) => {
|
||||
setScope((prev) => ({ ...prev, sources: next.sources }));
|
||||
setWindowOverride(
|
||||
next.window === savedDefaultWindow ? null : next.window,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sourcesRail={sourcesRail ?? <SourcesRail />}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
@@ -112,7 +111,6 @@ export function CoachSettingsSheet({
|
||||
onOpenChange,
|
||||
}: CoachSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: persisted } = useCoachPrefs({ enabled: open });
|
||||
|
||||
@@ -130,18 +128,11 @@ export function CoachSettingsSheet({
|
||||
setDraft(persisted);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (next: CoachPrefs) => {
|
||||
const res = await fetch("/api/auth/me/coach-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error("coach-prefs.save_failed");
|
||||
return (await res.json()) as { data: CoachPrefs };
|
||||
},
|
||||
onSuccess: (envelope) => {
|
||||
queryClient.setQueryData(["coach-prefs"], envelope.data);
|
||||
// v1.7.2 — write through the shared `useSaveCoachPrefs` mutation so
|
||||
// the cog and the chat-side sources rail persist via one code path
|
||||
// and seed the same `coachPrefs()` cache key on success.
|
||||
const save = useSaveCoachPrefs({
|
||||
onSuccess: () => {
|
||||
toast.success(t("insights.coach.settingsSaved"));
|
||||
onOpenChange(false);
|
||||
},
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Eye,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
Pill,
|
||||
Scale,
|
||||
Smile,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Activity, Eye, Loader2 } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,151 +13,83 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
CoachScope,
|
||||
CoachScopeSource,
|
||||
CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
coachDataClusterEnum,
|
||||
type CoachDataCluster,
|
||||
type CoachDefaultWindow,
|
||||
type CoachPrefs,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — "What I can see" rail.
|
||||
* v1.7.2 — "What I can see" rail, backed by the persisted Coach prefs.
|
||||
*
|
||||
* Right-column companion in the Coach drawer. Lists the data sources
|
||||
* the assistant draws on so the user can build trust in the
|
||||
* provenance.
|
||||
* The rail and the settings-cog sheet now drive the SAME persisted
|
||||
* `coachPrefsJson` row: the cluster toggles write `dataClusters` and the
|
||||
* window picker writes `defaultWindow` through
|
||||
* `PUT /api/auth/me/coach-prefs`. There is no ephemeral per-conversation
|
||||
* scope any more — what the rail shows is exactly what the chat route
|
||||
* ships to the model (the snapshot builder expands the saved clusters
|
||||
* when the request carries no explicit `scope.sources`). A toggle here
|
||||
* persists immediately and the cog reflects it on its next open, and
|
||||
* vice-versa.
|
||||
*
|
||||
* v1.4.20.1 — controls promoted from a static legend to a real scope
|
||||
* picker:
|
||||
* - per-source checkboxes (BP / Weight / Pulse / Mood / Compliance)
|
||||
* so the user can include/exclude a source from the next Coach
|
||||
* turn
|
||||
* - a window selector (last 7 / 30 / 90 days, all time) feeding
|
||||
* `buildCoachSnapshot`'s timeline window
|
||||
* Scope state lives in the parent drawer so `useSendCoachMessage`
|
||||
* can pass it through to the route on each send. The rail itself
|
||||
* stays presentational — it reads the current scope and emits an
|
||||
* `onScopeChange` whenever the user toggles a control. No
|
||||
* conversation-level persistence in v1.4.20.1; the rail resets to
|
||||
* "all sources, last 30 days" each time the drawer mounts fresh.
|
||||
* The rail is self-contained: it reads `useCoachPrefs` and writes via
|
||||
* `useSaveCoachPrefs`, so callers mount it with no props. The cluster
|
||||
* member count beneath each label spells out the scope so the user can
|
||||
* see how much data each toggle pulls in.
|
||||
*/
|
||||
const CLUSTER_OPTIONS: ReadonlyArray<CoachDataCluster> =
|
||||
coachDataClusterEnum.options;
|
||||
|
||||
const ALL_SOURCES: ReadonlyArray<CoachScopeSource> = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
];
|
||||
|
||||
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
|
||||
|
||||
/**
|
||||
* Default scope used by the drawer's initial mount. Exported so the
|
||||
* parent can seed its own state without re-deriving the same values.
|
||||
*/
|
||||
export const DEFAULT_COACH_SCOPE: Required<
|
||||
Pick<CoachScope, "sources" | "window">
|
||||
> = {
|
||||
sources: [...ALL_SOURCES],
|
||||
window: DEFAULT_WINDOW,
|
||||
};
|
||||
|
||||
export interface SourcesRailProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Current scope. When omitted the rail paints with the all-source
|
||||
* last-30-days defaults — useful in storyboards / unit tests that
|
||||
* just want to see the legend.
|
||||
*/
|
||||
scope?: CoachScope;
|
||||
/**
|
||||
* Fired when the user toggles a checkbox or changes the window.
|
||||
* The parent merges the change into its scope state. When omitted
|
||||
* the controls still render but stay read-only — keeps the legend
|
||||
* surface stable for callers that don't yet wire scope through.
|
||||
*/
|
||||
onScopeChange?: (next: {
|
||||
sources: CoachScopeSource[];
|
||||
window: CoachScopeWindow;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface SourceRow {
|
||||
key: CoachScopeSource;
|
||||
metricKey: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
accentClass: string;
|
||||
}
|
||||
|
||||
const ROWS: SourceRow[] = [
|
||||
{
|
||||
key: "bp",
|
||||
metricKey: "insights.coach.metric.bp",
|
||||
Icon: HeartPulse,
|
||||
accentClass: "text-dracula-purple",
|
||||
},
|
||||
{
|
||||
key: "weight",
|
||||
metricKey: "insights.coach.metric.weight",
|
||||
Icon: Scale,
|
||||
accentClass: "text-dracula-cyan",
|
||||
},
|
||||
{
|
||||
key: "pulse",
|
||||
metricKey: "insights.coach.metric.pulse",
|
||||
Icon: Heart,
|
||||
accentClass: "text-dracula-pink",
|
||||
},
|
||||
{
|
||||
key: "mood",
|
||||
metricKey: "insights.coach.metric.mood",
|
||||
Icon: Smile,
|
||||
accentClass: "text-dracula-green",
|
||||
},
|
||||
{
|
||||
key: "compliance",
|
||||
metricKey: "insights.coach.metric.compliance",
|
||||
Icon: Pill,
|
||||
accentClass: "text-dracula-orange",
|
||||
},
|
||||
];
|
||||
|
||||
const WINDOW_OPTIONS: ReadonlyArray<CoachScopeWindow> = [
|
||||
const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
export function SourcesRail({
|
||||
className,
|
||||
scope,
|
||||
onScopeChange,
|
||||
}: SourcesRailProps) {
|
||||
export interface SourcesRailProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourcesRail({ className }: SourcesRailProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const activeSources = new Set<CoachScopeSource>(
|
||||
scope?.sources && scope.sources.length > 0
|
||||
? scope.sources
|
||||
: DEFAULT_COACH_SCOPE.sources,
|
||||
);
|
||||
const activeWindow: CoachScopeWindow = scope?.window ?? DEFAULT_WINDOW;
|
||||
const interactive = !!onScopeChange;
|
||||
const { data: prefs } = useCoachPrefs();
|
||||
const save = useSaveCoachPrefs();
|
||||
|
||||
function toggleSource(key: CoachScopeSource) {
|
||||
if (!onScopeChange) return;
|
||||
const next = new Set(activeSources);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
onScopeChange({ sources: Array.from(next), window: activeWindow });
|
||||
// The persisted set drives the switches: `undefined` (never picked) →
|
||||
// legacy defaults, otherwise the explicit array (including the empty
|
||||
// "everything off" state). Track the cluster being written so its
|
||||
// switch shows a tiny spinner instead of the whole rail freezing.
|
||||
const enabledClusters = new Set<CoachDataCluster>(
|
||||
prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS,
|
||||
);
|
||||
const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime";
|
||||
const [pending, setPending] = useState<CoachDataCluster | "window" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function persist(next: CoachPrefs, marker: CoachDataCluster | "window") {
|
||||
setPending(marker);
|
||||
save.mutate(next, { onSettled: () => setPending(null) });
|
||||
}
|
||||
|
||||
function setWindow(next: CoachScopeWindow) {
|
||||
if (!onScopeChange) return;
|
||||
onScopeChange({
|
||||
sources: Array.from(activeSources),
|
||||
window: next,
|
||||
});
|
||||
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
|
||||
if (!prefs) return;
|
||||
const current = prefs.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
|
||||
const dataClusters = next
|
||||
? Array.from(new Set([...current, cluster]))
|
||||
: current.filter((c) => c !== cluster);
|
||||
persist({ ...prefs, dataClusters }, cluster);
|
||||
}
|
||||
|
||||
function setWindow(next: CoachDefaultWindow) {
|
||||
if (!prefs) return;
|
||||
persist({ ...prefs, defaultWindow: next }, "window");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -172,13 +97,6 @@ export function SourcesRail({
|
||||
data-slot="coach-sources-rail"
|
||||
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
|
||||
>
|
||||
{/* v1.4.33 — promote the rail label from a `<span>` to a real
|
||||
`<h3>` so the rail carries a semantic outline on desktop
|
||||
where it mounts inline next to the message thread. The
|
||||
mobile rail-tray wraps the rail inside its own `SheetTitle`
|
||||
(an `<h2>`), so the rail's own `<h3>` simply nests beneath
|
||||
it — the heading hierarchy stays consistent across
|
||||
viewports. */}
|
||||
<h3
|
||||
data-slot="coach-sources-rail-heading"
|
||||
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
|
||||
@@ -187,10 +105,9 @@ export function SourcesRail({
|
||||
{t("insights.coach.sourcesTitle")}
|
||||
</h3>
|
||||
|
||||
{/* Window selector — every row below is filtered to the picked
|
||||
window when the next Coach message goes out. 36px touch
|
||||
target on the trigger so the rail stays mobile-friendly when
|
||||
it surfaces inside the side-tray on `<xl`. */}
|
||||
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
|
||||
chosen timeframe sticks across drawer opens and matches what
|
||||
the cog sheet shows. */}
|
||||
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="coach-sources-window-select"
|
||||
@@ -200,8 +117,8 @@ export function SourcesRail({
|
||||
</label>
|
||||
<Select
|
||||
value={activeWindow}
|
||||
onValueChange={(v) => setWindow(v as CoachScopeWindow)}
|
||||
disabled={!interactive}
|
||||
onValueChange={(v) => setWindow(v as CoachDefaultWindow)}
|
||||
disabled={!prefs}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-sources-window-select"
|
||||
@@ -221,75 +138,66 @@ export function SourcesRail({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Persisted cluster toggles — the same set the settings cog
|
||||
edits. Toggling writes `dataClusters` immediately; the member
|
||||
count hint shows the scope each cluster pulls in. */}
|
||||
<ul
|
||||
data-slot="coach-sources-list"
|
||||
className="flex flex-1 flex-col gap-1.5"
|
||||
className="flex flex-1 flex-col gap-1.5 overflow-y-auto"
|
||||
>
|
||||
{ROWS.map((row) => {
|
||||
const checked = activeSources.has(row.key);
|
||||
const checkboxId = `coach-sources-toggle-${row.key}`;
|
||||
{CLUSTER_OPTIONS.map((cluster) => {
|
||||
const checked = enabledClusters.has(cluster);
|
||||
const switchId = `coach-sources-cluster-${cluster}`;
|
||||
const memberCount = CLUSTER_SOURCES[cluster].length;
|
||||
return (
|
||||
<li
|
||||
key={row.key}
|
||||
key={cluster}
|
||||
data-slot="coach-sources-row"
|
||||
data-source={row.key}
|
||||
data-source={cluster}
|
||||
data-active={checked ? "true" : "false"}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/30 flex items-center gap-2",
|
||||
"border-border/60 bg-muted/30 flex items-center gap-2.5",
|
||||
"rounded-md border px-2.5",
|
||||
// 36px target so a finger tap reliably lands on the
|
||||
// checkbox on mobile (the side-tray surface).
|
||||
"min-h-9 py-1.5",
|
||||
"min-h-11 py-1.5",
|
||||
!checked && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* v1.4.27 R3d MB4 / CF-40 — swap the raw HTML checkbox
|
||||
for the shadcn `<Checkbox>` primitive so the rail
|
||||
ships a real focus ring, a proper keyboard contract
|
||||
(Space toggles, Tab moves), and a touch-friendly
|
||||
hit target on the side-tray surface. The wrapping
|
||||
`<label htmlFor={checkboxId}>` below still acts as
|
||||
the row-wide tap target on mobile. */}
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={checked}
|
||||
disabled={!interactive}
|
||||
onCheckedChange={() => toggleSource(row.key)}
|
||||
data-slot="coach-sources-checkbox"
|
||||
aria-label={t(row.metricKey)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<row.Icon
|
||||
className={cn("size-3.5", row.accentClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className={cn(
|
||||
"text-foreground flex-1 cursor-pointer text-xs font-medium",
|
||||
!interactive && "cursor-default",
|
||||
)}
|
||||
>
|
||||
{t(row.metricKey)}
|
||||
</label>
|
||||
{/* Fresh / stale indicator — v1.4.20 was a static dot
|
||||
with no real freshness state behind it (v1.4.21 plugs
|
||||
in <IntegrationStatusPill>). aria-hidden so SR users
|
||||
don't hear "Fresh" five times in a row for what is
|
||||
actually a placeholder. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-dracula-green size-1.5 rounded-full"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
className="text-foreground cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(`insights.coach.cluster.${cluster}.label`)}
|
||||
</label>
|
||||
<span className="text-muted-foreground text-[10px] leading-snug">
|
||||
{t("insights.coach.sourcesMemberCount", {
|
||||
count: memberCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{pending === cluster ? (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
id={switchId}
|
||||
data-slot="coach-sources-checkbox"
|
||||
checked={checked}
|
||||
disabled={!prefs || pending !== null}
|
||||
onCheckedChange={(next) => toggleCluster(cluster, next)}
|
||||
aria-label={t(`insights.coach.cluster.${cluster}.label`)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* v1.4.22 B4: the rail's footer now carries the medical
|
||||
disclaimer (relocated from below the composer). The
|
||||
source-picker section above stands on its own as the rail's
|
||||
primary content — users see the disclaimer once, in a calm
|
||||
place, instead of every time they look at the input. */}
|
||||
|
||||
{/* Medical disclaimer footer (relocated from the composer in
|
||||
v1.4.22). */}
|
||||
<div
|
||||
data-slot="coach-sources-disclaimer"
|
||||
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -35,3 +35,35 @@ export function useCoachPrefs(opts?: { enabled?: boolean }) {
|
||||
enabled: opts?.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.2 — shared writer for the Coach preferences row. Both the
|
||||
* settings sheet and the chat-side sources rail persist the same
|
||||
* `coachPrefsJson` through `PUT /api/auth/me/coach-prefs`, so the write
|
||||
* path lives here next to the reader. On success the canonical defaulted
|
||||
* shape the route echoes back is seeded into the `coachPrefs()` cache so
|
||||
* every surface that reads the hook re-renders against one source of
|
||||
* truth — the rail and the cog can never drift.
|
||||
*/
|
||||
export function useSaveCoachPrefs(opts?: { onSuccess?: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.coachPrefs(),
|
||||
mutationFn: async (next: CoachPrefs) => {
|
||||
const res = await fetch("/api/auth/me/coach-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error("coach-prefs.save_failed");
|
||||
return (await res.json()) as { data: CoachPrefs };
|
||||
},
|
||||
onSuccess: (envelope) => {
|
||||
queryClient.setQueryData<CoachPrefs>(
|
||||
queryKeys.coachPrefs(),
|
||||
envelope.data,
|
||||
);
|
||||
opts?.onSuccess?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user