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",
"last90days": "letzte 90 Tage",
"allTime": "gesamter Zeitraum"
}
},
"windowLabel": "Zeitraum"
},
"viewAll": "Alle ansehen",
"previewEmpty": "Noch keine Empfehlungen.",
+2 -1
View File
@@ -883,7 +883,8 @@
"last30days": "last 30 days",
"last90days": "last 90 days",
"allTime": "all time"
}
},
"windowLabel": "Window"
},
"viewAll": "View all",
"previewEmpty": "No recommendations yet.",
@@ -50,4 +50,26 @@ describe("<SourcesRail>", () => {
"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 { 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 `<Sheet>` 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<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(
(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={
sourcesRail ?? (
<SourcesRail
scope={scope}
onScopeChange={setScope}
/>
)
}
thread={
<MessageThread
conversation={conversation ?? null}
@@ -291,7 +328,9 @@ export function CoachDrawer({
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? <SourcesRail />}
{sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
)}
</div>
</SheetContent>
</Sheet>
@@ -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 `<IntegrationStatusPill>`.
* 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<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: 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<CoachScopeWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
export function SourcesRail({
className,
scope,
onScopeChange,
}: 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;
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 (
<div
data-slot="coach-sources-rail"
@@ -85,38 +174,103 @@ export function SourcesRail({ className }: SourcesRailProps) {
{t("insights.coach.sourcesTitle")}
</span>
</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
data-slot="coach-sources-list"
className="flex flex-1 flex-col gap-1.5"
>
{ROWS.map((row) => (
<li
key={row.key}
data-slot="coach-sources-row"
data-source={row.key}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2",
"rounded-md border px-2.5 py-2",
)}
>
<row.Icon
className={cn("size-3.5", row.accentClass)}
aria-hidden="true"
/>
<span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)}
</span>
{/* Fresh / stale indicator — v1.4.20 is 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>
))}
{ROWS.map((row) => {
const checked = activeSources.has(row.key);
const checkboxId = `coach-sources-toggle-${row.key}`;
return (
<li
key={row.key}
data-slot="coach-sources-row"
data-source={row.key}
data-active={checked ? "true" : "false"}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2",
"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",
!checked && "opacity-60",
)}
>
<input
id={checkboxId}
type="checkbox"
checked={checked}
disabled={!interactive}
onChange={() => 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",
)}
/>
<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>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
<Activity
@@ -8,6 +8,7 @@ import type {
CoachConversationDTO,
CoachConversationsPage,
CoachProvenance,
CoachScope,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
@@ -214,6 +215,12 @@ export interface SendCoachMessageParams {
message: string;
prefill?: string;
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 {
@@ -283,6 +290,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
message: params.message,
prefill: params.prefill,
locale: params.locale,
scope: params.scope,
}),
signal: controller.signal,
});
@@ -135,4 +135,24 @@ describe("buildCoachSnapshot", () => {
]);
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);
});
});