Merge remote-tracking branch 'origin/feat/coach-lookback-limit' into release/v1.40.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:31:34 +02:00
commit 359203d09a
61 files changed
+2552 -285

No files matched your search

@@ -124,10 +124,53 @@ describe("<SourcesRail>", () => {
expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/);
});
it("shows the per-cluster member-count hint", () => {
it("shows the per-cluster member-count hint, singular for one", () => {
const html = render(<SourcesRail />);
// cardio expands to 9 sources, mood to 1 — both surface a count.
expect(html).toContain("9 metrics");
expect(html).toContain("1 metrics");
expect(html).toContain("1 metric<");
expect(html).not.toContain("1 metrics");
});
it("never fades a switched-off row (contrast)", () => {
// `opacity-60` over muted text fell below WCAG AA in both themes. The
// off state is the dashed frame and the switch, the text keeps its
// tokens.
const html = render(<SourcesRail />);
const rows = html.match(/<li[^>]*data-slot="coach-sources-row"[^>]*>/g);
expect(rows?.length).toBe(10);
for (const row of rows ?? []) expect(row).not.toMatch(/opacity/);
expect(html).toMatch(
/data-source="glucose"[^>]*data-active="false"[^>]*class="[^"]*border-dashed/,
);
});
it("names the lookback a limit and states what the default reads", () => {
const html = render(<SourcesRail />);
expect(html).toContain("How far back the Coach can look");
// `allTime` is the default: the sentence carries the numbers the
// readers use (`history-reach.ts`).
expect(html).toContain(
"Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.",
);
expect(html).not.toContain("stay visible");
});
it("states the limit and what stays visible under a bounded lookback", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "last90days" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 90 days, in any answer.",
);
expect(html).toContain("stay visible");
expect(html).toContain('aria-describedby="coach-sources-window-detail"');
});
it("offers the twelve-month limit", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "lastYear" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 12 months, in any answer.",
);
});
});
@@ -16,6 +16,11 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
coachDataClusterEnum,
@@ -45,12 +50,12 @@ import {
const CLUSTER_OPTIONS: ReadonlyArray<CoachDataCluster> =
coachDataClusterEnum.options;
const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
/**
* The lookback options, narrowest first. The choice is a limit
* the server enforces on every Coach read (`history-reach.ts`), so the text
* under the picker says what is read, with the numbers the readers use.
*/
const WINDOW_OPTIONS = COACH_HISTORY_WINDOWS;
export interface SourcesRailProps {
className?: string;
@@ -67,7 +72,7 @@ export interface SourcesRailProps {
}
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
const { t } = useTranslations();
const { t, tCount } = useTranslations();
const { data: prefs } = useCoachPrefs();
const save = useSaveCoachPrefs();
@@ -80,6 +85,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime";
const activeText = lookbackText(activeWindow);
const bounded = HISTORY_REACH_DAYS[activeWindow] !== null;
const [pending, setPending] = useState<CoachDataCluster | "window" | null>(
null,
);
@@ -151,15 +158,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
</div>
) : null}
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
chosen timeframe sticks across drawer opens and matches what
the cog sheet shows. */}
{/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line
under the picker states what the Coach reads at this setting, with
the numbers the server readers use. */}
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
{t("insights.coach.lookback.label")}
</label>
<Select
value={activeWindow}
@@ -171,17 +178,32 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-sm"
aria-describedby="coach-sources-window-detail"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-sm">
{t(`insights.coach.window.${w}`)}
{t(
`insights.coach.lookback.option.${w}`,
lookbackText(w).optionParams,
)}
</SelectItem>
))}
</SelectContent>
</Select>
<p
id="coach-sources-window-detail"
data-slot="coach-sources-window-detail"
className="text-muted-foreground text-xs leading-snug"
>
{t(
`insights.coach.lookback.${activeText.detailKey}`,
activeText.detailParams,
)}
{bounded ? ` ${t("insights.coach.lookback.note")}` : null}
</p>
</div>
{/* Persisted cluster toggles — the same set the settings cog
@@ -201,11 +223,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
data-slot="coach-sources-row"
data-source={cluster}
data-active={checked ? "true" : "false"}
// A switched-off row is told apart by its frame (dashed, no
// fill) and its switch, never by fading the text: opacity over
// muted text fell below WCAG AA in both themes.
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2.5",
"rounded-lg border px-3",
"flex items-center gap-2.5 rounded-lg border px-3",
"min-h-11 py-2",
!checked && "opacity-60",
checked
? "border-border/60 bg-muted/30"
: "border-border border-dashed bg-transparent",
)}
>
<div className="flex min-w-0 flex-1 flex-col">
@@ -216,9 +242,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
{t(`insights.coach.cluster.${cluster}.label`)}
</label>
<span className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.sourcesMemberCount", {
count: memberCount,
})}
{tCount("insights.coach.sourcesMemberCount", memberCount)}
</span>
</div>
{pending === cluster ? (
@@ -34,6 +34,12 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
@@ -776,6 +782,12 @@ function CoachSettingsForm({
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
const savedWindow = asString(preferences.defaultWindow, "allTime");
const [lookback, setLookback] = useState<CoachDefaultWindow>(
(COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow)
? (savedWindow as CoachDefaultWindow)
: "allTime",
);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -847,28 +859,44 @@ function CoachSettingsForm({
</option>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
{/* The same lookback options and wording as the Coach's own picker:
the choice is a limit on every Coach read. */}
<div className="grid gap-1 text-sm">
<label htmlFor="managed-coach-window">
{t("insights.coach.lookback.label")}
</label>
<NativeSelect
defaultValue={asString(preferences.defaultWindow, "allTime")}
value={lookback}
onChange={(event) =>
setLookback(event.currentTarget.value as CoachDefaultWindow)
}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
aria-describedby="managed-coach-window-detail"
>
<option value="last7days">
{t("settings.sharedRecord.managedSettings.coach.last7days")}
</option>
<option value="last30days">
{t("settings.sharedRecord.managedSettings.coach.last30days")}
</option>
<option value="last90days">
{t("settings.sharedRecord.managedSettings.coach.last90days")}
</option>
<option value="allTime">
{t("settings.sharedRecord.managedSettings.coach.allTime")}
</option>
{COACH_HISTORY_WINDOWS.map((window) => (
<option key={window} value={window}>
{t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
)}
</option>
))}
</NativeSelect>
</label>
<p
id="managed-coach-window-detail"
className="text-muted-foreground text-xs"
>
{t(
`insights.coach.lookback.${lookbackText(lookback).detailKey}`,
lookbackText(lookback).detailParams,
)}
{HISTORY_REACH_DAYS[lookback] !== null
? ` ${t("insights.coach.lookback.note")}`
: null}
</p>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">