diff --git a/messages/de.json b/messages/de.json index d1e6637fa..1a7f0f4c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -883,7 +883,8 @@ "last30days": "letzte 30 Tage", "last90days": "letzte 90 Tage", "allTime": "gesamter Zeitraum" - } + }, + "windowLabel": "Zeitraum" }, "viewAll": "Alle ansehen", "previewEmpty": "Noch keine Empfehlungen.", diff --git a/messages/en.json b/messages/en.json index 5b5ea3a6e..abc5d5af8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -883,7 +883,8 @@ "last30days": "last 30 days", "last90days": "last 90 days", "allTime": "all time" - } + }, + "windowLabel": "Window" }, "viewAll": "View all", "previewEmpty": "No recommendations yet.", 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 522282074..d454ef3c0 100644 --- a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx @@ -50,4 +50,26 @@ describe("", () => { "The Coach reads only the data you've connected.", ); }); + + it("renders a checkbox per row + a window selector trigger", () => { + const html = render(); + const checkboxes = ( + html.match(/data-slot="coach-sources-checkbox"/g) ?? [] + ).length; + expect(checkboxes).toBe(5); + 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"/); + }); }); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 07ccf139b..234701c1e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -24,8 +24,13 @@ import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; import { HistoryRail } from "./history-rail"; import { MessageThread } from "./message-thread"; -import { SourcesRail } from "./sources-rail"; +import { DEFAULT_COACH_SCOPE, 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). @@ -89,6 +94,17 @@ export function CoachDrawer({ // open/prefill transition. That sidesteps `setState`-in-`useEffect` // entirely (banned by `react-hooks/set-state-in-effect`). const [inputValue, setInputValue] = useState(() => 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. + const [scope, setScope] = useState<{ + sources: CoachScopeSource[]; + window: CoachScopeWindow; + }>(() => ({ + sources: [...DEFAULT_COACH_SCOPE.sources], + window: DEFAULT_COACH_SCOPE.window, + })); const handleOpenChange = useCallback( (next: boolean) => { @@ -115,9 +131,23 @@ 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". + const isDefault = + scope.window === DEFAULT_COACH_SCOPE.window && + scope.sources.length === DEFAULT_COACH_SCOPE.sources.length && + DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s)); + const scopePayload: CoachScope | undefined = isDefault + ? undefined + : { + sources: scope.sources, + window: scope.window, + }; await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, + scope: scopePayload, }); } @@ -226,7 +256,14 @@ export function CoachDrawer({ /> ) } - sourcesRail={sourcesRail ?? } + sourcesRail={ + sourcesRail ?? ( + + ) + } thread={
- {sourcesRail ?? } + {sourcesRail ?? ( + + )}
diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index ee167a53d..cc5de7b41 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -10,30 +10,83 @@ import { Smile, } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} 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"; /** * v1.4.20 phase B2b — "What I can see" rail. * * Right-column companion in the Coach drawer. Lists the data sources * the assistant draws on so the user can build trust in the - * provenance. v1.4.20 ships with a hard-coded list keyed off the - * five contracts the snapshot helper currently surfaces (BP, weight, - * pulse, mood, medication compliance). v1.4.21 will plug in fresh / - * stale dot indicators by reusing ``. + * provenance. * - * Hardcoding the list here is deliberate: every line is i18n-driven - * so locale switching works out of the box, and the underlying user - * data is already gated behind the page-level `requireAuth()` so a - * user without a connected source still sees the legend. + * 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. */ + +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: string; + key: CoachScopeSource; metricKey: string; Icon: React.ComponentType<{ className?: string }>; accentClass: string; @@ -72,8 +125,44 @@ const ROWS: SourceRow[] = [ }, ]; -export function SourcesRail({ className }: SourcesRailProps) { +const WINDOW_OPTIONS: ReadonlyArray = [ + "last7days", + "last30days", + "last90days", + "allTime", +]; + +export function SourcesRail({ + className, + scope, + onScopeChange, +}: 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; + + 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 }); + } + + function setWindow(next: CoachScopeWindow) { + if (!onScopeChange) return; + onScopeChange({ + sources: Array.from(activeSources), + window: next, + }); + } + return (
+ + {/* 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 ` + + + +
    - {ROWS.map((row) => ( -
  • -
  • - ))} + {ROWS.map((row) => { + const checked = activeSources.has(row.key); + const checkboxId = `coach-sources-toggle-${row.key}`; + return ( +
  • + toggleSource(row.key)} + data-slot="coach-sources-checkbox" + aria-label={t(row.metricKey)} + className={cn( + "border-border/70 text-dracula-purple", + "size-4 shrink-0 cursor-pointer rounded", + "focus-visible:ring-ring/50 focus-visible:ring-2", + "disabled:cursor-not-allowed disabled:opacity-50", + )} + /> +
  • + ); + })}
{ ]); expect(parsed.scope.window).toBe("last30days"); }); + + it("scope-only-mood pulls just mood data, no measurements query", async () => { + featuresMock.mockResolvedValue({ + mood: { avg30: 4.2, coverage: { count: 12 } }, + }); + prismaMock.moodEntry.findMany.mockResolvedValue([ + { + moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + score: 4, + }, + ]); + + const out = await buildCoachSnapshot("user-1", { + sources: ["mood"], + }); + expect(prismaMock.measurement.findMany).not.toHaveBeenCalled(); + expect(out.provenance.metrics).toContain("mood"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.mood.timeline.recent.length).toBe(1); + }); });