feat(coach): per-source + window scope picker on sources rail

Previously the Sources rail listed BP / Weight / Pulse / Mood /
Compliance as a static legend. Marc asked for a real "what I can
see" surface — per-source checkboxes (include/exclude) plus a
window selector (last 7d / 30d / 90d / all-time) that flow through
the Coach request body.

Wire the controls all the way down:
  - <SourcesRail> grows scope + onScopeChange props. Each row
    becomes a 36px tall flex row with a real checkbox; the row
    fades to 60% opacity when its source is excluded.
  - A Select trigger above the list drives the window. Both control
    surfaces share the existing insights.coach.window.* i18n keys;
    add a new windowLabel key so the dropdown has an EN+DE label.
  - <CoachDrawer> owns the scope state (resets on each mount per
    the v1.4.20.1 plan — no conversation-level persistence yet) and
    forwards the picked scope through useSendCoachMessage to the
    chat request body. The payload only ships when the user has
    narrowed away from the all-source last30days default so the
    server can tell "no opinion" from "intentionally narrow".
  - buildCoachSnapshot already accepts the optional CoachScope from
    the previous commit; it now flows end-to-end.

No new dependencies — checkboxes are plain inputs styled with
Tailwind + Dracula tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 19:27:28 +02:00
1 parent ddb2914b65
commit 08fd411ff1
7 files changed
+288 -43

No files matched your search

+2 -1
View File
@@ -883,7 +883,8 @@
"last30days": "letzte 30 Tage", "last30days": "letzte 30 Tage",
"last90days": "letzte 90 Tage", "last90days": "letzte 90 Tage",
"allTime": "gesamter Zeitraum" "allTime": "gesamter Zeitraum"
} },
"windowLabel": "Zeitraum"
}, },
"viewAll": "Alle ansehen", "viewAll": "Alle ansehen",
"previewEmpty": "Noch keine Empfehlungen.", "previewEmpty": "Noch keine Empfehlungen.",
+2 -1
View File
@@ -883,7 +883,8 @@
"last30days": "last 30 days", "last30days": "last 30 days",
"last90days": "last 90 days", "last90days": "last 90 days",
"allTime": "all time" "allTime": "all time"
} },
"windowLabel": "Window"
}, },
"viewAll": "View all", "viewAll": "View all",
"previewEmpty": "No recommendations yet.", "previewEmpty": "No recommendations yet.",
@@ -50,4 +50,26 @@ describe("<SourcesRail>", () => {
"The Coach reads only the data you&#x27;ve connected.", "The Coach reads only the data you&#x27;ve connected.",
); );
}); });
it("renders a checkbox per row + a window selector trigger", () => {
const html = render(<SourcesRail />);
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(
<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"/);
});
}); });
@@ -24,8 +24,13 @@ import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input"; import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail"; import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread"; 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 { 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). * v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
@@ -89,6 +94,17 @@ export function CoachDrawer({
// open/prefill transition. That sidesteps `setState`-in-`useEffect` // open/prefill transition. That sidesteps `setState`-in-`useEffect`
// entirely (banned by `react-hooks/set-state-in-effect`). // entirely (banned by `react-hooks/set-state-in-effect`).
const [inputValue, setInputValue] = useState<string>(() => prefill ?? ""); const [inputValue, setInputValue] = useState<string>(() => 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( const handleOpenChange = useCallback(
(next: boolean) => { (next: boolean) => {
@@ -115,9 +131,23 @@ export function CoachDrawer({
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || send.isStreaming) return; if (!trimmed || send.isStreaming) return;
setInputValue(""); 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({ await send.send({
conversationId: currentConversationId ?? undefined, conversationId: currentConversationId ?? undefined,
message: trimmed, message: trimmed,
scope: scopePayload,
}); });
} }
@@ -226,7 +256,14 @@ export function CoachDrawer({
/> />
) )
} }
sourcesRail={sourcesRail ?? <SourcesRail />} sourcesRail={
sourcesRail ?? (
<SourcesRail
scope={scope}
onScopeChange={setScope}
/>
)
}
thread={ thread={
<MessageThread <MessageThread
conversation={conversation ?? null} conversation={conversation ?? null}
@@ -291,7 +328,9 @@ export function CoachDrawer({
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="h-full min-h-0 overflow-y-auto"> <div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? <SourcesRail />} {sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
)}
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -10,30 +10,83 @@ import {
Smile, Smile,
} from "lucide-react"; } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context"; 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. * v1.4.20 phase B2b — "What I can see" rail.
* *
* Right-column companion in the Coach drawer. Lists the data sources * Right-column companion in the Coach drawer. Lists the data sources
* the assistant draws on so the user can build trust in the * 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 * provenance.
* 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 `<IntegrationStatusPill>`.
* *
* Hardcoding the list here is deliberate: every line is i18n-driven * v1.4.20.1 — controls promoted from a static legend to a real scope
* so locale switching works out of the box, and the underlying user * picker:
* data is already gated behind the page-level `requireAuth()` so a * - per-source checkboxes (BP / Weight / Pulse / Mood / Compliance)
* user without a connected source still sees the legend. * 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<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 { export interface SourcesRailProps {
className?: string; 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 { interface SourceRow {
key: string; key: CoachScopeSource;
metricKey: string; metricKey: string;
Icon: React.ComponentType<{ className?: string }>; Icon: React.ComponentType<{ className?: string }>;
accentClass: string; accentClass: string;
@@ -72,8 +125,44 @@ const ROWS: SourceRow[] = [
}, },
]; ];
export function SourcesRail({ className }: SourcesRailProps) { const WINDOW_OPTIONS: ReadonlyArray<CoachScopeWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
export function SourcesRail({
className,
scope,
onScopeChange,
}: SourcesRailProps) {
const { t } = useTranslations(); 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;
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 ( return (
<div <div
data-slot="coach-sources-rail" data-slot="coach-sources-rail"
@@ -85,38 +174,103 @@ export function SourcesRail({ className }: SourcesRailProps) {
{t("insights.coach.sourcesTitle")} {t("insights.coach.sourcesTitle")}
</span> </span>
</div> </div>
{/* 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`. */}
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground px-1 text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
<Select
value={activeWindow}
onValueChange={(v) => setWindow(v as CoachScopeWindow)}
disabled={!interactive}
>
<SelectTrigger
id="coach-sources-window-select"
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-xs">
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ul <ul
data-slot="coach-sources-list" data-slot="coach-sources-list"
className="flex flex-1 flex-col gap-1.5" className="flex flex-1 flex-col gap-1.5"
> >
{ROWS.map((row) => ( {ROWS.map((row) => {
<li const checked = activeSources.has(row.key);
key={row.key} const checkboxId = `coach-sources-toggle-${row.key}`;
data-slot="coach-sources-row" return (
data-source={row.key} <li
className={cn( key={row.key}
"border-border/60 bg-muted/30 flex items-center gap-2", data-slot="coach-sources-row"
"rounded-md border px-2.5 py-2", data-source={row.key}
)} data-active={checked ? "true" : "false"}
> className={cn(
<row.Icon "border-border/60 bg-muted/30 flex items-center gap-2",
className={cn("size-3.5", row.accentClass)} "rounded-md border px-2.5",
aria-hidden="true" // 36px target so a finger tap reliably lands on the
/> // checkbox on mobile (the side-tray surface).
<span className="text-foreground flex-1 text-xs font-medium"> "min-h-9 py-1.5",
{t(row.metricKey)} !checked && "opacity-60",
</span> )}
{/* Fresh / stale indicator — v1.4.20 is a static dot >
with no real freshness state behind it (v1.4.21 plugs <input
in <IntegrationStatusPill>). aria-hidden so SR users id={checkboxId}
don't hear "Fresh" five times in a row for what is type="checkbox"
actually a placeholder. */} checked={checked}
<span disabled={!interactive}
aria-hidden="true" onChange={() => toggleSource(row.key)}
className="bg-dracula-green size-1.5 rounded-full" data-slot="coach-sources-checkbox"
/> aria-label={t(row.metricKey)}
</li> 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",
)}
/>
<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"
/>
</li>
);
})}
</ul> </ul>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"> <div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
<Activity <Activity
@@ -8,6 +8,7 @@ import type {
CoachConversationDTO, CoachConversationDTO,
CoachConversationsPage, CoachConversationsPage,
CoachProvenance, CoachProvenance,
CoachScope,
CoachStreamEvent, CoachStreamEvent,
} from "@/lib/ai/coach/types"; } from "@/lib/ai/coach/types";
@@ -214,6 +215,12 @@ export interface SendCoachMessageParams {
message: string; message: string;
prefill?: string; prefill?: string;
locale?: "en" | "de"; locale?: "en" | "de";
/**
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
* driven by the sources-rail picker. When omitted the server falls
* back to all-source last30days, matching the legacy behaviour.
*/
scope?: CoachScope;
} }
export interface UseSendCoachMessageOptions { export interface UseSendCoachMessageOptions {
@@ -283,6 +290,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
message: params.message, message: params.message,
prefill: params.prefill, prefill: params.prefill,
locale: params.locale, locale: params.locale,
scope: params.scope,
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@@ -135,4 +135,24 @@ describe("buildCoachSnapshot", () => {
]); ]);
expect(parsed.scope.window).toBe("last30days"); 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);
});
}); });