mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'fix/v1475-speed' into release/v1.47.5
# Conflicts: # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
b5e118d086
88 files changed
+1475
-337
No files matched your search
@@ -15,7 +15,11 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { cachesToEvict, resolveVersionPollDecision } from "../version-poller";
|
||||
import {
|
||||
cachesToEvict,
|
||||
resolveVersionPollDecision,
|
||||
versionCheckDueOnReturn,
|
||||
} from "../version-poller";
|
||||
|
||||
describe("resolveVersionPollDecision", () => {
|
||||
it("is up-to-date when live matches the shell", () => {
|
||||
@@ -172,3 +176,20 @@ describe("cachesToEvict", () => {
|
||||
expect(cachesToEvict(keys, "1.43.0")).not.toContain("another-app-cache");
|
||||
});
|
||||
});
|
||||
|
||||
describe("versionCheckDueOnReturn", () => {
|
||||
const NOW = 1_000_000_000;
|
||||
|
||||
it("asks at once when the tab comes back after a full interval away", () => {
|
||||
expect(versionCheckDueOnReturn("visible", NOW - 60_000, NOW)).toBe(true);
|
||||
expect(versionCheckDueOnReturn("visible", 0, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask again on a quick switch back", () => {
|
||||
expect(versionCheckDueOnReturn("visible", NOW - 5_000, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("never asks while the tab is hidden", () => {
|
||||
expect(versionCheckDueOnReturn("hidden", 0, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiQualitySection } from "../ai-quality-section";
|
||||
import { CoachFeedbackSection } from "../coach-feedback-section";
|
||||
|
||||
function render() {
|
||||
@@ -138,3 +139,25 @@ describe("CoachFeedbackSection", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Coach admin page's two quality cards", () => {
|
||||
it("read one cache cell, so the page asks for the summary once", () => {
|
||||
useQueryMock.mockReset().mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: null,
|
||||
});
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiQualitySection />
|
||||
<CoachFeedbackSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const keys = useQueryMock.mock.calls.map(
|
||||
([options]) => (options as { queryKey: unknown }).queryKey,
|
||||
);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[1]).toEqual(keys[0]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,10 @@ import type {
|
||||
ReadinessSignal,
|
||||
RecoveryReadiness,
|
||||
} from "@/components/admin/use-recovery-readiness";
|
||||
import { READINESS_FIX_PATHS } from "@/lib/recovery/readiness-model";
|
||||
import {
|
||||
READINESS_FIX_PATHS,
|
||||
READINESS_SIGNALS,
|
||||
} from "@/lib/recovery/readiness-model";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
@@ -97,6 +100,23 @@ function row(html: string, id: string): string {
|
||||
}
|
||||
|
||||
describe("RecoveryReadinessCard", () => {
|
||||
it("holds one row per signal while it loads, so the cards below stay put", () => {
|
||||
// A read that has not answered: no data, no error.
|
||||
const client = new QueryClient();
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<RecoveryReadinessCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const start = html.indexOf('data-testid="recovery-readiness-loading"');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(html.slice(start).match(/<li/g)?.length).toBe(
|
||||
READINESS_SIGNALS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every signal with its state and sentence", () => {
|
||||
pathname.current = "/admin";
|
||||
const html = render(READINESS);
|
||||
|
||||
@@ -51,7 +51,9 @@ export function CoachFeedbackSection() {
|
||||
const fmt = useFormatters();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.adminCoachFeedback(),
|
||||
// The same read as the AI quality section beside it, so one request
|
||||
// serves both cards.
|
||||
queryKey: queryKeys.adminAiQuality(),
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<{ summary: FeedbackSummary | null } | null>(
|
||||
"/api/admin/ai-quality",
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LifeBuoy,
|
||||
Loader2,
|
||||
MinusCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -34,6 +33,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -44,6 +44,17 @@ import {
|
||||
type RecoveryReadiness,
|
||||
} from "./use-recovery-readiness";
|
||||
|
||||
/** One placeholder row per readiness signal the card always lists. */
|
||||
const READINESS_SIGNAL_SLOTS = [
|
||||
"database_dump",
|
||||
"weekly_copy",
|
||||
"offhost_copies",
|
||||
"restore_drill",
|
||||
"offhost_setup",
|
||||
"dump_retention",
|
||||
"recovery_kit",
|
||||
] as const satisfies readonly ReadinessSignal["id"][];
|
||||
|
||||
const STATE_ICON: Record<
|
||||
ReadinessState,
|
||||
React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
|
||||
@@ -194,14 +205,27 @@ export function RecoveryReadinessCard() {
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
// Holds the list's height while it loads. The card opens the backups
|
||||
// page, and a one-line spinner here pushed every card below it down
|
||||
// by the whole list once the signals landed.
|
||||
<div data-testid="recovery-readiness-loading">
|
||||
<span className="sr-only">
|
||||
{t("admin.recoveryReadiness.loading")}
|
||||
</span>
|
||||
<ul className="divide-border divide-y" aria-hidden="true">
|
||||
{READINESS_SIGNAL_SLOTS.map((slot) => (
|
||||
<li
|
||||
key={slot}
|
||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Skeleton className="h-5 w-40 max-w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -73,8 +73,8 @@ async function readDailySeries({
|
||||
async function fetchSleepNights() {
|
||||
const sleepParams = new URLSearchParams();
|
||||
sleepParams.set("kind", "sleep");
|
||||
// The series route caps sleep at 365 days; clamp the request so a
|
||||
// longer comparison window still resolves.
|
||||
// The series route caps `days` at 3650 (the "All" tab); clamp the
|
||||
// request so a window stretched by a comparison shift still resolves.
|
||||
sleepParams.set(
|
||||
"days",
|
||||
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CycleCalendar } from "../cycle-calendar";
|
||||
import { CycleCalendar, CycleCalendarSkeleton } from "../cycle-calendar";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
@@ -242,3 +242,20 @@ describe("<CycleCalendar>", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CycleCalendarSkeleton", () => {
|
||||
// November 2026 starts on a Sunday and spans six weeks; February 2027
|
||||
// starts on a Monday and spans four.
|
||||
for (const today of ["2026-11-11", "2027-02-10"]) {
|
||||
it(`holds as many weeks as the month it stands in for (${today})`, () => {
|
||||
const calendar = render(<CycleCalendar days={[]} today={today} />);
|
||||
const skeleton = render(<CycleCalendarSkeleton today={today} />);
|
||||
|
||||
// Every row but the weekday header is one week.
|
||||
const weeks = (calendar.match(/role="row"/g)?.length ?? 0) - 1;
|
||||
const cells = skeleton.match(/aspect-square/g)?.length ?? 0;
|
||||
expect(weeks).toBeGreaterThan(3);
|
||||
expect(cells).toBe(weeks * 7);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
@@ -65,6 +66,43 @@ function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar's frame while the page's read is in flight: the month header
|
||||
* and the weeks of `today`'s month in the grid's own cell size, so the card
|
||||
* keeps the height the month takes instead of growing from a line of text.
|
||||
* The loading text stays for screen readers.
|
||||
*/
|
||||
export function CycleCalendarSkeleton({ today }: { today: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [y, m] = today.split("-").map(Number);
|
||||
const daysInMonth = new Date(y, m, 0).getDate();
|
||||
const weeks = Math.ceil(
|
||||
(mondayIndex(new Date(y, m - 1, 1)) + daysInMonth) / 7,
|
||||
);
|
||||
return (
|
||||
<div data-slot="cycle-calendar-skeleton">
|
||||
<span className="sr-only">{t("cycle.calendar.loading")}</span>
|
||||
<div aria-hidden="true" className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-md pointer-coarse:min-h-11 pointer-coarse:min-w-11" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="size-10 rounded-md pointer-coarse:min-h-11 pointer-coarse:min-w-11" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<div key={`weekday-${i}`} className="text-2xs pb-1">
|
||||
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: weeks * 7 }, (_, i) => (
|
||||
<Skeleton key={i} className="aspect-square min-h-11 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
const CELL_DATE_FORMATS = new Map<Locale, Intl.DateTimeFormat>();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { CycleCalendar } from "./cycle-calendar";
|
||||
import { CycleCalendar, CycleCalendarSkeleton } from "./cycle-calendar";
|
||||
import { CycleHistoryChart } from "./cycle-history-chart";
|
||||
import { LogDaySheet, sheetDayContext } from "./log-day-sheet";
|
||||
import { PhaseEducationCard } from "./phase-education-card";
|
||||
@@ -347,9 +347,7 @@ export function CycleView() {
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.calendar.loading")}
|
||||
</p>
|
||||
<CycleCalendarSkeleton today={today} />
|
||||
) : calendarError ? (
|
||||
// Inside the calendar card already — a borderless row keeps
|
||||
// it in line with the plain loading line above it (§3/§6).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { primeMessages } from "@/lib/i18n/load-locale";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
primeMessages("de", deMessages);
|
||||
|
||||
/**
|
||||
* A metric page with no data offers the Coach a question in the reader's
|
||||
* language. The pages used to pass their own English sentence, and most of
|
||||
* them were one sentence with the metric's name swapped in; the page now asks
|
||||
* that one, translated, with its own title, and a metric with a question of
|
||||
* its own reads it from the catalog too.
|
||||
*/
|
||||
|
||||
const prefills: Array<string | null | undefined> = [];
|
||||
vi.mock("@/components/insights/metric-empty-state", () => ({
|
||||
MetricEmptyState: ({ coachPrefill }: { coachPrefill?: string | null }) => {
|
||||
prefills.push(coachPrefill);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: true, reason: null }),
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({ default: () => () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/insights/visceral-fat",
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "UTC", dateOfBirth: null, gender: null },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({ data: { summaries: {} }, isEmpty: true }),
|
||||
}));
|
||||
|
||||
import InsightsViszeralfettPage from "@/app/insights/visceral-fat/page";
|
||||
import InsightsStepsPage from "@/app/insights/steps/page";
|
||||
|
||||
function emptyStatePrefill(node: React.ReactNode, locale: "en" | "de") {
|
||||
prefills.length = 0;
|
||||
renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return prefills.at(-1);
|
||||
}
|
||||
|
||||
describe("the empty metric page's Coach question", () => {
|
||||
it("is the shared sentence with the page's title, in the reader's language", () => {
|
||||
expect(emptyStatePrefill(<InsightsViszeralfettPage />, "de")).toBe(
|
||||
"Viszeralfett: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?",
|
||||
);
|
||||
expect(emptyStatePrefill(<InsightsViszeralfettPage />, "en")).toBe(
|
||||
"Visceral Fat: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a metric's own question, translated", () => {
|
||||
expect(emptyStatePrefill(<InsightsStepsPage />, "de")).toBe(
|
||||
"Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The layout shell wraps every Insights page. It owns the regenerate button
|
||||
* on the tab strip but shows no briefing, so it must not read the briefing
|
||||
* on entry: on a metric page that read was a request (and the schema chunk
|
||||
* that parses its answer) for a button. The button itself stays.
|
||||
*/
|
||||
|
||||
const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
|
||||
const stripProps: Array<{ onRegenerate?: unknown }> = [];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
|
||||
queries.push(options);
|
||||
return { data: undefined, isLoading: false, isError: false };
|
||||
},
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
|
||||
useQueryClient: () => ({
|
||||
getQueryCache: () => ({ find: () => undefined }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: true }),
|
||||
useAiProviderState: () => ({ responseTimeoutMs: null }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-surface-visible", () => ({
|
||||
useSurfaceVisible: () => true,
|
||||
}));
|
||||
vi.mock("@/hooks/use-workouts", () => ({
|
||||
useWorkouts: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/lib/queries/use-analytics-query", () => ({
|
||||
useAnalyticsQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout", () => ({
|
||||
useInsightsLayoutQuery: () => ({
|
||||
layout: { tiles: [] },
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/insights/use-ecg-list", () => ({
|
||||
useEcgList: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/components/insights/insights-tab-strip", () => ({
|
||||
InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
|
||||
stripProps.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { InsightsLayoutShell } from "../insights-layout-shell";
|
||||
|
||||
describe("InsightsLayoutShell — what it reads on entry", () => {
|
||||
it("keeps the regenerate button and sends no briefing read", () => {
|
||||
renderToStaticMarkup(
|
||||
<InsightsLayoutShell>
|
||||
<div />
|
||||
</InsightsLayoutShell>,
|
||||
);
|
||||
|
||||
const advisorKey = JSON.stringify(queryKeys.insightsAdvisor());
|
||||
const advisor = queries.filter(
|
||||
(q) => JSON.stringify(q.queryKey) === advisorKey,
|
||||
);
|
||||
expect(advisor).toHaveLength(1);
|
||||
expect(advisor[0].enabled).toBe(false);
|
||||
expect(typeof stripProps[0]?.onRegenerate).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The tab strip owns the regenerate button on every Insights page but shows
|
||||
* no briefing, so it no longer reads one on entry. A regenerate from a page
|
||||
* that never read the briefing still needs the briefing it replaces as the
|
||||
* baseline a timed-out attempt settles against; without it the old briefing
|
||||
* would read as the fresh one. `ensureAdvisorBaseline` reads it first, and
|
||||
* only when the cache holds none.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ensureAdvisorBaseline } from "../use-insights-advisor";
|
||||
|
||||
const CACHED_AT = "2026-11-10T05:00:00.000Z";
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchRaw.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: { insights: {}, cached: true, cachedAt: CACHED_AT },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
describe("ensureAdvisorBaseline", () => {
|
||||
it("reads the briefing a regenerate replaces when nothing has read it yet", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetchRaw.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
const cached = queryClient.getQueryData<{
|
||||
payload: { cachedAt: string } | null;
|
||||
}>(queryKeys.insightsAdvisor());
|
||||
expect(cached?.payload?.cachedAt).toBe(CACHED_AT);
|
||||
});
|
||||
|
||||
it("asks nothing when the overview already read it", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.insightsAdvisor(), {
|
||||
payload: { cachedAt: CACHED_AT },
|
||||
outcome: "fresh",
|
||||
});
|
||||
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
|
||||
expect(apiFetchRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the baseline empty when the read fails", async () => {
|
||||
apiFetchRaw.mockRejectedValue(new Error("offline"));
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await expect(ensureAdvisorBaseline(queryClient)).resolves.toBeUndefined();
|
||||
expect(
|
||||
queryClient.getQueryData(queryKeys.insightsAdvisor()),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -148,9 +148,8 @@ export interface HealthKitMetricPageProps {
|
||||
*/
|
||||
statIcon?: ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Optional Coach prefill for the empty-state launch. Falls back to a
|
||||
* generic onboarding prompt threaded through `<CoachLaunchButton>`'s
|
||||
* default.
|
||||
* The empty state's Coach question, for a metric that asks its own. Without
|
||||
* it the page asks the shared, translated one with its own title.
|
||||
*/
|
||||
coachPrefill?: string;
|
||||
/**
|
||||
@@ -223,6 +222,17 @@ export interface HealthKitMetricPageProps {
|
||||
afterAssessment?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state's Coach question for a metric that asks none of its own:
|
||||
* one translated sentence around the page's title.
|
||||
*/
|
||||
function sharedCoachPrefill(
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
metric: string,
|
||||
): string {
|
||||
return t("insights.subPage.emptyCoachPrefill", { metric });
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
measurementType,
|
||||
fallbackMeasurementType,
|
||||
@@ -424,7 +434,7 @@ export function HealthKitMetricPage({
|
||||
title={t(`${i18nPrefix}.emptyState.title`)}
|
||||
description={t(`${i18nPrefix}.emptyState.description`)}
|
||||
cta={ctaNode}
|
||||
coachPrefill={coachPrefill ?? null}
|
||||
coachPrefill={coachPrefill ?? sharedCoachPrefill(t, title)}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -24,12 +24,12 @@ import { useEcgList } from "@/components/insights/use-ecg-list";
|
||||
/**
|
||||
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
|
||||
*
|
||||
* Owns the advisor query so the regenerate button on the sticky tab
|
||||
* strip works from every Insights surface (mother + the seven sub-
|
||||
* pages). TanStack Query dedup keeps the cost negligible: the mother
|
||||
* page's own `useInsightsAdvisorQuery` consumer shares the same
|
||||
* `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
|
||||
* same cache entry without extra network traffic.
|
||||
* Owns the regenerate button on the sticky tab strip, so it works from
|
||||
* every Insights surface. The strip shows no briefing, so the shell does
|
||||
* not read it: the overview's own `useInsightsAdvisorQuery` does, and the
|
||||
* shell's hook reads it only while its regenerate settles. Every metric
|
||||
* page used to send the briefing read (and load the schema chunk that
|
||||
* parses it) for a button.
|
||||
*
|
||||
* v1.4.27 — also owns the analytics + comprehensive reads that
|
||||
* power the tab-strip availability gate. The analytics consumer goes
|
||||
@@ -55,8 +55,8 @@ interface ComprehensivePayload {
|
||||
|
||||
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// The briefing read and the regenerate button follow the `briefing`
|
||||
// capability on `/api/auth/me`: the server resolves the operator's switch,
|
||||
// The regenerate button follows the `briefing` capability on
|
||||
// `/api/auth/me`: the server resolves the operator's switch,
|
||||
// the person's AI analysis opt-out, provider presence and consent into one
|
||||
// answer, and the strip offers regenerate only while that answer is yes.
|
||||
// Unavailable while `/me` loads, so nothing fires before the answer lands.
|
||||
@@ -68,7 +68,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const mounted = useMounted();
|
||||
const advisorEnabled = mounted && isAuthenticated && briefing.available;
|
||||
|
||||
const advisor = useInsightsAdvisorQuery(advisorEnabled);
|
||||
const advisor = useInsightsAdvisorQuery(false);
|
||||
|
||||
// Shared analytics fetch — the layout shell only reads
|
||||
// `summaries[METRIC].count` for the tab-strip availability gate, so
|
||||
|
||||
@@ -61,20 +61,17 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
* - Active pill gets `aria-current="page"`.
|
||||
* - The regenerate button has an `aria-label` so the icon-only
|
||||
* affordance is announced. The button only renders when
|
||||
* `onRegenerate` is wired — sub-pages don't carry the regenerate
|
||||
* handler today (the mother page owns the advisor query and
|
||||
* forwards the trigger via the layout slot).
|
||||
* `onRegenerate` is wired; the layout shell wires it on every
|
||||
* Insights page while the briefing capability is available.
|
||||
* - `prefers-reduced-motion` honoured at the global level (no
|
||||
* scroll-into-view here — Next.js routing handles focus + scroll).
|
||||
*/
|
||||
|
||||
export interface InsightsTabStripProps {
|
||||
/**
|
||||
* Optional regenerate handler. Only the mother page passes this;
|
||||
* sub-pages render the strip without the right-slot button.
|
||||
* Wiring the strip in `layout.tsx` means we'd need a layout-level
|
||||
* advisor query — for v1.4.25 we keep the strip stateless and the
|
||||
* regenerate affordance on the mother page only.
|
||||
* Optional regenerate handler. The layout shell passes it on every
|
||||
* Insights page while the briefing capability is available; without
|
||||
* it the strip renders no customise or regenerate control.
|
||||
*/
|
||||
onRegenerate?: () => void;
|
||||
/** Spinner state — disables the button and swaps the icon. */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { pollAttemptCount } from "@/lib/queries/refetch-interval";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
// Type-only — the runtime schemas load lazily inside `fetchAdvisor` so
|
||||
@@ -429,6 +434,27 @@ export async function fetchAdvisor(
|
||||
return { payload, outcome: "fresh" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload a regenerate settles against. A surface that shows no briefing
|
||||
* (the tab strip on a metric page) has not read it, and a timed-out
|
||||
* regenerate without a baseline takes whatever briefing the cache holds for
|
||||
* the fresh one. So the read runs first when the cache is empty; a failed
|
||||
* read leaves the baseline empty, as it was before.
|
||||
*/
|
||||
export async function ensureAdvisorBaseline(
|
||||
queryClient: QueryClient,
|
||||
): Promise<void> {
|
||||
if (queryClient.getQueryData(queryKeys.insightsAdvisor()) !== undefined) {
|
||||
return;
|
||||
}
|
||||
await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
queryFn: () => fetchAdvisor(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface UseInsightsAdvisorResult {
|
||||
payload: InsightAdvisorPayload | null;
|
||||
isLoading: boolean;
|
||||
@@ -485,7 +511,9 @@ export interface UseInsightsAdvisorResult {
|
||||
* available, whatever the caller passes: every consumer (the overview, the
|
||||
* tab strip) gets the same gate, and none can re-enable the read behind the
|
||||
* other's back. While `/me` loads the capability reads unavailable, so the
|
||||
* first paint never fires a request only to have it refused.
|
||||
* first paint never fires a request only to have it refused. The tab strip
|
||||
* passes `false`: it shows no briefing and only owns the regenerate button,
|
||||
* so it reads the briefing only while its own regenerate settles.
|
||||
*/
|
||||
export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
@@ -506,7 +534,9 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome) so the read path can surface WHY a payload is missing
|
||||
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
|
||||
queryFn: () => fetchAdvisor(),
|
||||
enabled: enabled && briefing.available,
|
||||
// A caller that shows no briefing still reads it while its own
|
||||
// regenerate settles: the settle is decided on these polls.
|
||||
enabled: (enabled || settling !== null) && briefing.available,
|
||||
// 24h cache window matches the server-side `insightsCachedAt` TTL.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
@@ -556,7 +586,10 @@ export function useInsightsAdvisorQuery(
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }),
|
||||
mutationFn: async () => {
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
return fetchAdvisor({ force: true, responseTimeoutMs });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.payload) {
|
||||
// A genuinely fresh generation landed — write it into the shared
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The monitoring switches come from the root layout's render, not from a
|
||||
* request every page load sent to `/api/monitoring/settings` for three
|
||||
* instance-wide values. The first render already mounts what they turn on.
|
||||
*/
|
||||
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
}));
|
||||
vi.mock("@/components/monitoring/umami-script", () => ({
|
||||
UmamiScript: (props: { enabled: boolean; websiteId: string | null }) => (
|
||||
<i data-umami={`${props.enabled}:${props.websiteId}`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/monitoring/glitchtip-reporter", () => ({
|
||||
GlitchtipReporter: (props: { enabled: boolean }) => (
|
||||
<i data-glitchtip={String(props.enabled)} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { MonitoringBootstrap } from "../bootstrap";
|
||||
|
||||
describe("MonitoringBootstrap", () => {
|
||||
it("mounts what the layout's switches turn on, without asking the server", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MonitoringBootstrap
|
||||
settings={{
|
||||
umamiEnabled: true,
|
||||
umamiWebsiteId: "site-1",
|
||||
glitchtipEnabled: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-umami="true:site-1"');
|
||||
expect(html).toContain('data-glitchtip="true"');
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts nothing without settings", () => {
|
||||
expect(renderToStaticMarkup(<MonitoringBootstrap settings={null} />)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { UmamiScript } from "@/components/monitoring/umami-script";
|
||||
import { GlitchtipReporter } from "@/components/monitoring/glitchtip-reporter";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface MonitoringSettings {
|
||||
umamiEnabled: boolean;
|
||||
umamiWebsiteId: string | null;
|
||||
glitchtipEnabled: boolean;
|
||||
}
|
||||
|
||||
export function MonitoringBootstrap() {
|
||||
const [settings, setSettings] = useState<MonitoringSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
apiGet<MonitoringSettings | undefined>("/api/monitoring/settings", {
|
||||
cache: "no-store",
|
||||
})
|
||||
.then((data) => {
|
||||
if (!active || !data) return;
|
||||
setSettings(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setSettings({
|
||||
umamiEnabled: false,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
import type { PublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
|
||||
/**
|
||||
* Mounts the operator's analytics script and error reporter. The root layout
|
||||
* reads the instance-wide switches on the server and passes them in, so no
|
||||
* page load asks `/api/monitoring/settings` for three values the document
|
||||
* render already had. `null` (the key-mismatch page) mounts nothing.
|
||||
*/
|
||||
export function MonitoringBootstrap({
|
||||
settings,
|
||||
}: {
|
||||
settings: Pick<
|
||||
PublicMonitoringSettings,
|
||||
"umamiEnabled" | "umamiWebsiteId" | "glitchtipEnabled"
|
||||
> | null;
|
||||
}) {
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,8 @@ import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
|
||||
* the user trips the error — typically in the middle of a
|
||||
* navigation flow.
|
||||
*
|
||||
* This component runs the check pro-actively. Every 60 s it fetches
|
||||
* This component runs the check pro-actively. Every 60 s while the tab
|
||||
* is in view (and once on coming back to it) it fetches
|
||||
* `/api/version` and compares the live version string against
|
||||
* `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build
|
||||
* time by `next.config.ts`). When the live version moves ahead of
|
||||
@@ -93,6 +94,19 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tab that comes back into view checks the version now. A hidden
|
||||
* tab skips its ticks, so the first look after a full interval away is due at
|
||||
* once; a quick switch back and forth is not a reason to ask again.
|
||||
*/
|
||||
export function versionCheckDueOnReturn(
|
||||
visibility: DocumentVisibilityState,
|
||||
lastCheckedAt: number,
|
||||
now: number,
|
||||
): boolean {
|
||||
return visibility === "visible" && now - lastCheckedAt >= POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
// In-memory mirror of the session guard: under strict-privacy modes
|
||||
// sessionStorage throws, and without a fallback a persistent
|
||||
// shell/live mismatch (misconfigured build) would reload every poll.
|
||||
@@ -165,8 +179,10 @@ export function VersionPoller(): null {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let lastCheckedAt = 0;
|
||||
|
||||
async function checkOnce(): Promise<void> {
|
||||
lastCheckedAt = Date.now();
|
||||
const live = await fetchLiveVersion(controller.signal);
|
||||
let lastReloadedFor: string | null = null;
|
||||
try {
|
||||
@@ -201,16 +217,31 @@ export function VersionPoller(): null {
|
||||
}
|
||||
|
||||
// First check 5 s after mount — gives the app a chance to settle
|
||||
// before we surface anything. Then on the recurring interval.
|
||||
const initial = window.setTimeout(() => void checkOnce(), 5_000);
|
||||
const interval = window.setInterval(
|
||||
() => void checkOnce(),
|
||||
POLL_INTERVAL_MS,
|
||||
);
|
||||
// before we surface anything. Then on the recurring interval, but only
|
||||
// while the tab is in view: a background tab asks nothing and checks
|
||||
// once when it comes back.
|
||||
const tick = () => {
|
||||
if (document.visibilityState === "visible") void checkOnce();
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (
|
||||
versionCheckDueOnReturn(
|
||||
document.visibilityState,
|
||||
lastCheckedAt,
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
void checkOnce();
|
||||
}
|
||||
};
|
||||
const initial = window.setTimeout(tick, 5_000);
|
||||
const interval = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
controller.abort();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
Reference in new issue
Block a user