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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-05-31 19:39:35 +02:00
1 parent 91f23bae68
commit c48541e74f
11 files changed
+269 -422

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+33 -1
View File
@@ -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?.();
},
});
}