mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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",
|
"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
@@ -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've connected.",
|
"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 { 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user