diff --git a/messages/de.json b/messages/de.json index 27f2e6ce4..5076b1e23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 236bf61e6..524e50c1a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c23648866..6721d9ebc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index a3289559a..877109ca5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e7368b5d0..b7956882a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e351b3e05..02d9c4561 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx index 3e4b974ff..721114943 100644 --- a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx @@ -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("", () => { it("renders the rail wrapper + label", () => { const html = render(); @@ -17,68 +47,85 @@ describe("", () => { expect(html).toContain("What I can see"); }); - it("renders the rail label as a real `

` heading (v1.4.33)", () => { - // The rail mounts inline on `xl+` desktop where no `SheetTitle` - // wrapper covers it. Promoting the label to `

` restores the - // drawer's semantic outline so screen-reader users can navigate - // by heading. + it("renders the rail label as a real `

` heading", () => { const html = render(); expect(html).toMatch( /]*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(); 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(); - 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(, "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(); 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(); - 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( - 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(); + 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(); + 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(); + // cardio expands to 9 sources, mood to 1 — both surface a count. + expect(html).toContain("9 metrics"); + expect(html).toContain("1 metrics"); }); }); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 517887c9e..ef97a5396 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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 `` 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( - 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")} - {/* 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 ` - - + {/* 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 ?? ( - { - setScope((prev) => ({ ...prev, sources: next.sources })); - setWindowOverride( - next.window === savedDefaultWindow ? null : next.window, - ); - }} - /> - ) - } + sourcesRail={sourcesRail ?? } thread={ { - setScope((prev) => ({ ...prev, sources: next.sources })); - setWindowOverride( - next.window === savedDefaultWindow ? null : next.window, - ); - }} - /> - ) - } + sourcesRail={sourcesRail ?? } /> diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index c08338b31..909304064 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -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); }, diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 58660995d..2ba92d15b 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -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 = + coachDataClusterEnum.options; -const ALL_SOURCES: ReadonlyArray = [ - "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 -> = { - 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 = [ +const WINDOW_OPTIONS: ReadonlyArray = [ "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( - 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( + prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS, + ); + const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime"; + const [pending, setPending] = useState( + 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 `` to a real - `

` 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 `

`), so the rail's own `

` simply nests beneath - it — the heading hierarchy stays consistent across - viewports. */}

- {/* 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 `