mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Make the Coach lookback a limit on every read
The saved Coach window only seeded the snapshot and each tool's default. A tool could still read back to all time, the availability probe reported counts and means for history outside the window, and several blocks read fixed windows of their own (labs 12 months, correlations 180 days, the coarse tail, the all-time extremes) whatever the setting said. coachPrefs.defaultWindow is now enforced as a limit through one module, history-reach.ts: the turn context clamps the client window, the snapshot clamps its window and gives every fixed-window block either the shorter window or, when the block's own window cannot fit, leaves it out. Every retrieval tool clamps its window, an earlier period or a stored table that reaches past the limit answers outside_reach, and the probe reads only inside the limit and names older history as outside_reach without any figure. Follow-up chips that would read past the limit are not offered. The default (allTime) is no limit and reads as before. MCP clients pass no limit and keep their own window arguments. Adds the twelve-month option, rewrites the picker text to state what is read with the numbers the readers use, shows the same options in the managed-profile settings, counts cluster members with a singular, and drops the opacity on switched-off rows that put their text below WCAG AA.
This commit is contained in:
1 parent
3be83eee22
commit
393ac955f6
60 files changed
+2539
-304
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">
|
||||
|
||||
Reference in new issue
Block a user