feat(coach): default analysis window as user preference + per-conversation override

`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:28:10 +02:00
1 parent 2bd29022b1
commit 330be965ac
10 files changed
+331 -8

No files matched your search

@@ -104,4 +104,14 @@ describe("<CoachSettingsSheet>", () => {
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved.");
});
// v1.4.25 W5 — defaultWindow picker added.
it("renders the default-window picker once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-default-window"');
expect(html).toContain("Default analysis window");
});
});
@@ -5,6 +5,13 @@ import type { Dispatch, SetStateAction } from "react";
import { Plus, Settings, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
@@ -14,6 +21,7 @@ import {
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
@@ -153,6 +161,13 @@ export function CoachDrawer({
// 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.
//
// v1.4.25 W5 — the scope.window field is now a two-layer override:
// • the user's saved `coachPrefs.defaultWindow` is the base
// • the header pill drops a per-conversation override into
// `windowOverride`; null = "use the saved default"
// The override resets to null on drawer close so the next session
// starts on the user's saved preference.
const [scope, setScope] = useState<{
sources: CoachScopeSource[];
window: CoachScopeWindow;
@@ -160,15 +175,31 @@ export function CoachDrawer({
sources: [...DEFAULT_COACH_SCOPE.sources],
window: DEFAULT_COACH_SCOPE.window,
}));
const [windowOverride, setWindowOverride] =
useState<CoachScopeWindow | null>(null);
// v1.4.25 W5 — load the user's saved Coach prefs so the default
// window picks up the cog's saved selection. The hook gates the
// fetch on `enabled` so the network call only fires while the
// drawer is open. Falls through to the legacy "last30days" default
// when the row is missing or the request is in flight.
const { data: coachPrefs } = useCoachPrefs({ enabled: open });
const savedDefaultWindow: CoachScopeWindow =
coachPrefs?.defaultWindow ?? DEFAULT_COACH_SCOPE.window;
const effectiveWindow: CoachScopeWindow =
windowOverride ?? savedDefaultWindow;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
// Reset thread + composer on close so the next open starts on
// the rail's empty hint instead of re-rendering the previous
// conversation by accident.
// conversation by accident. v1.4.25 W5 — also drop the
// per-conversation window override so the next session starts
// on the user's saved `coachPrefs.defaultWindow` again.
setCurrentConversationId(null);
setInputValue("");
setWindowOverride(null);
}
onOpenChange(next);
},
@@ -189,15 +220,21 @@ export function CoachDrawer({
// 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 &&
//
// v1.4.25 W5 — `scope.window` now reflects the per-conversation
// override (header pill / rail picker) layered on top of the saved
// `coachPrefs.defaultWindow`. Send the override only when the user
// explicitly diverged from the saved default; otherwise the route
// re-applies the saved preference itself.
const allSourcesSelected =
scope.sources.length === DEFAULT_COACH_SCOPE.sources.length &&
DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s));
const isDefault = allSourcesSelected && windowOverride === null;
const scopePayload: CoachScope | undefined = isDefault
? undefined
: {
sources: scope.sources,
window: scope.window,
window: effectiveWindow,
};
await send.send({
conversationId: currentConversationId ?? undefined,
@@ -256,6 +293,49 @@ export function CoachDrawer({
{t("insights.coach.tagline")}
</SheetDescription>
</div>
{/* v1.4.25 W5 — per-conversation window override. The pill
defaults to the user's saved `coachPrefs.defaultWindow`
and resets to it on drawer close. Changing the pill flips
`windowOverride`; the rail's window picker mirrors the
same source-of-truth so the user can drive the override
from either surface. */}
<Select
value={effectiveWindow}
onValueChange={(value) => {
const next = value as CoachScopeWindow;
setWindowOverride(next === savedDefaultWindow ? null : next);
}}
>
<SelectTrigger
data-slot="coach-drawer-window-pill"
aria-label={t("insights.coach.windowLabel")}
size="sm"
className={cn(
"border-border/60 bg-muted/40 text-foreground h-7 shrink-0 gap-1 rounded-full px-2.5 text-[11px]",
"hover:bg-muted/60 focus-visible:ring-ring/40 focus-visible:ring-2",
windowOverride !== null &&
"border-dracula-purple/40 bg-dracula-purple/10 text-dracula-purple",
)}
>
<SelectValue
placeholder={t(`insights.coach.window.${effectiveWindow}`)}
/>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="last7days" className="text-xs">
{t("insights.coach.window.last7days")}
</SelectItem>
<SelectItem value="last30days" className="text-xs">
{t("insights.coach.window.last30days")}
</SelectItem>
<SelectItem value="last90days" className="text-xs">
{t("insights.coach.window.last90days")}
</SelectItem>
<SelectItem value="allTime" className="text-xs">
{t("insights.coach.window.allTime")}
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
@@ -297,7 +377,20 @@ export function CoachDrawer({
}
sourcesRail={
sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
<SourcesRail
// v1.4.25 W5 — overlay the effective window on the rail
// so the picker mirrors the per-conversation override
// (or the saved default when no override is active).
// The rail's own onChange flips `windowOverride` so the
// rail picker is just another way to set the override.
scope={{ sources: scope.sources, window: effectiveWindow }}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)
}
thread={
@@ -374,7 +467,18 @@ export function CoachDrawer({
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)}
</div>
</SheetContent>
@@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_PREFS,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -71,6 +72,16 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"steps",
];
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
// straight into `scope.window` when the client didn't supply one.
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
export function CoachSettingsSheet({
open,
onOpenChange,
@@ -237,6 +248,46 @@ export function CoachSettingsSheet({
</Select>
</div>
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
scope window the Coach reads when the client doesn't
supply a per-conversation override. The drawer header
carries a small pill that overrides the picked default
for the current chat only. */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Exclude metrics */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
@@ -86,6 +86,11 @@ const METRIC_ICON: Record<
distance: Route,
vo2_max: Zap,
body_temp: Thermometer,
// ── v1.4.25 W4d GLP-1 additive ──
// Plateau finding inherits the Pill icon since it's an
// adherence-context observation about the user's GLP-1 therapy.
// Non-GLP-1 accounts never see this finding type.
glp1_plateau: Pill,
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {