mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
ddb2914b65
commit
08fd411ff1
7 files changed
+288
-43
No files matched your search
+2
-1
@@ -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
@@ -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'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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user