mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge remote-tracking branch 'origin/feat/coach-lookback-limit' into release/v1.40.2
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user