Merge branch 'fix/v1475-speed' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:51:36 +01:00
commit b5e118d086
88 files changed
+1475 -337

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 93833,
"total": 93830,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -11,7 +11,7 @@
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1087,
"components/insights/insights-tab-strip.tsx": 1084,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/medication-table.tsx": 853,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 336,
"total": 109345,
"total": 109328,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -27,7 +27,7 @@
"app/api/mcp/oauth/token/route.ts :: POST": 250,
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241,
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
@@ -80,7 +80,7 @@
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 405,
"components/cycle/cycle-view.tsx :: CycleView": 403,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The health status card reads two engines that walk every vital over up to
* 180 days. The answer is kept in the record's analytics cell: a second open
* of Insights serves it, and a write that sweeps the record's cells, or a
* module switch, computes it again.
*/
const computeCoincidentDeviation = vi.fn();
const buildExtendedChangepointSignals = vi.fn();
const resolveModuleMap = vi.fn();
vi.mock("@/lib/insights/derived/coincident-deviation", () => ({
computeCoincidentDeviation: (...args: unknown[]) =>
computeCoincidentDeviation(...args),
}));
vi.mock("@/lib/insights/derived/changepoint", () => ({
buildExtendedChangepointSignals: (...args: unknown[]) =>
buildExtendedChangepointSignals(...args),
}));
vi.mock("@/lib/insights/derived", () => ({
loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }),
}));
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const call = () => GET();
beforeEach(() => {
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER",
timezone: "Europe/Berlin",
},
} as unknown as Awaited<ReturnType<typeof getSession>>);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
computeCoincidentDeviation
.mockReset()
.mockResolvedValue({ status: "insufficient" });
buildExtendedChangepointSignals.mockReset().mockResolvedValue([]);
resolveModuleMap.mockReset().mockResolvedValue({});
});
describe("GET /api/insights/health-status — the cached answer", () => {
it("computes once for two opens", async () => {
const first = await call();
const second = await call();
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1);
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1);
expect((await second.json()).data).toEqual((await first.json()).data);
});
it("computes again after a measurement write", async () => {
await call();
invalidateUserMeasurements("user-1", { evict: true });
await call();
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2);
});
it("computes again when a module is switched off", async () => {
await call();
resolveModuleMap.mockResolvedValue({ sleep: false });
await call();
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2);
expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith(
"user-1",
expect.any(Date),
{ sleep: false },
);
});
});
+43 -21
View File
@@ -24,8 +24,10 @@ import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint";
import { resolveModuleMap } from "@/lib/modules/gate";
import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate";
import { summariseHealthStatus } from "@/lib/insights/health-status";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -39,36 +41,56 @@ export const GET = apiHandler(async () => {
return apiError("Too many analytics requests. Please retry later.", 429);
}
// Both engines walk every vital over up to 180 days, so the answer goes
// through the analytics cell its sibling reads use. Measurement and mood
// writes sweep the record's cells; the reader's day and the switched-off
// modules are part of the key, so a new day or a module switch never serves
// the other answer.
const modules = await resolveModuleMap(user.id);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
const now = new Date();
const profile = await loadBaselineProfile(prisma, user.id);
const coincident = await computeCoincidentDeviation(user.id, profile, {
tz: user.timezone,
now,
});
// v1.44 — the wider scan: every vital with a stable daily level over 180
// days, a metric whose module is off left unread.
const shifts = await buildExtendedChangepointSignals(
user.id,
now,
await resolveModuleMap(user.id),
const day = userDayKey(now, user.timezone ?? DEFAULT_TIMEZONE);
const body = await cachedSwr(
caches.analytics as ServerCache<HealthStatusBody>,
`${user.id}|health-status|${day}|off:${modulesOff.join(",")}`,
() => buildHealthStatus(user.id, user.timezone, now, modules),
annotate,
);
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
const summary = summariseHealthStatus(vitals, shifts);
annotate({
action: { name: "insights.health-status.read" },
meta: {
deviations: summary.deviations.length,
shifts: summary.shifts.length,
deviations: body.deviations.length,
shifts: body.shifts.length,
},
});
return apiSuccess({
return apiSuccess(body);
});
type HealthStatusBody = Awaited<ReturnType<typeof buildHealthStatus>>;
async function buildHealthStatus(
userId: string,
timezone: string | undefined,
now: Date,
modules: Awaited<ReturnType<typeof resolveModuleMap>>,
) {
const profile = await loadBaselineProfile(prisma, userId);
const coincident = await computeCoincidentDeviation(userId, profile, {
tz: timezone,
now,
});
// v1.44 — the wider scan: every vital with a stable daily level over 180
// days, a metric whose module is off left unread.
const shifts = await buildExtendedChangepointSignals(userId, now, modules);
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
const summary = summariseHealthStatus(vitals, shifts);
return {
present: summary.present,
deviations: summary.deviations,
shifts: summary.shifts,
generatedAt: now.toISOString(),
});
});
};
}
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn() },
},
}));
@@ -567,3 +567,74 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102
});
}
});
/**
* The history reads the window's rows, not every intake the medication ever
* had: for a medication taken for years each open of the tab read thousands
* of rows to show at most a year. Of the rows before the window only the
* latest take is read, because a rolling grid re-starts from it.
*/
describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => {
const FROM = new Date("2026-06-01T00:00:00Z");
const TO = new Date("2026-06-15T00:00:00Z");
const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`;
it("asks only for rows anchored or taken in the window", async () => {
await GET(getReq(windowQuery), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toMatchObject({
OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }],
});
const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock
.calls[0][0];
expect(before?.where).toMatchObject({
skipped: false,
scheduledFor: { lt: FROM },
takenAt: { lt: FROM },
});
});
it("re-starts a rolling grid from the latest take before the window", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: 7,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
// Taken four days before the window opens, nothing since: the next dose
// was due inside the window and reads as missed there.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
takenAt: new Date("2026-05-28T06:00:00Z"),
} as never);
const res = await GET(getReq(windowQuery), ROUTE_PARAMS);
expect(res.status).toBe(200);
const rows = (await res.json()).data.rows as Array<{
at: string;
kind: string;
status: string;
}>;
expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([
["2026-06-04", "slot", "missed"],
]);
});
});
@@ -101,6 +101,61 @@ interface SerializedDoseHistoryRow {
} | null;
}
/**
* The intakes a window needs: the rows anchored in it or taken in it (a late
* take on an older slot is an intake instant of the window), and of all the
* rows before it only the latest take, the anchor a rolling grid re-starts
* from and the last intake when none falls in the window. Earlier takes place
* no slot in the window. The whole history used to be read on every open, so
* a medication taken for years read thousands of rows to show at most a year.
*/
async function readWindowIntakes(
medicationId: string,
userId: string,
from: Date,
) {
const [events, takenBefore] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
where: {
medicationId,
userId,
deletedAt: null,
OR: [{ scheduledFor: { gte: from } }, { takenAt: { gte: from } }],
},
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
id: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
attributionSource: true,
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
doseTaken: true,
// v1.32.8 (iOS #64) — write provenance so the client can label how
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
source: true,
// v1.42.2 — the history labels an automatically recorded dose.
autoRecorded: true,
},
}),
prisma.medicationIntakeEvent.findFirst({
where: {
medicationId,
userId,
deletedAt: null,
skipped: false,
scheduledFor: { lt: from },
takenAt: { lt: from },
},
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { takenAt: true },
}),
]);
return { events, anchorBefore: takenBefore?.takenAt ?? null };
}
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -165,26 +220,7 @@ export const GET = apiHandler(
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id, deletedAt: null },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
id: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
attributionSource: true,
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
doseTaken: true,
// v1.32.8 (iOS #64) — write provenance so the client can label how
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
source: true,
// v1.42.2 — the history labels an automatically recorded dose.
autoRecorded: true,
},
});
const { events, anchorBefore } = await readWindowIntakes(id, user.id, from);
const mapped = events.map((e) => ({
id: e.id,
@@ -198,7 +234,10 @@ export const GET = apiHandler(
autoRecorded: e.autoRecorded,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
const lastIntakeAt = lastNonSkippedTakenAt([
...mapped,
...(anchorBefore ? [{ takenAt: anchorBefore, skipped: false }] : []),
]);
// A dose may be recorded before its slot's own time: the dose window opens
// up to two hours early, and a skip can be logged ahead. Such a row is
@@ -263,6 +302,7 @@ export const GET = apiHandler(
const intakeInstants = mapped
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to)
.map((e) => e.takenAt as Date)
.concat(anchorBefore ? [anchorBefore] : [])
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedulesWithEras({
+3 -1
View File
@@ -3,6 +3,7 @@
import { Flame } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.32 — `/insights/active-energy`.
@@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* imports collapse cleanly via the v1.4.30 drain script.
*/
export default function InsightsActiveEnergyPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="ACTIVE_ENERGY_BURNED"
@@ -27,7 +29,7 @@ export default function InsightsActiveEnergyPage() {
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
coachPrefill={t("insights.activeEnergy.coachPrefill")}
/>
);
}
-1
View File
@@ -26,7 +26,6 @@ export default function InsightsLaermereignissePage() {
yAxisUnit=""
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any loud-environment events yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -60,7 +60,6 @@ export default function InsightsBlutzuckerPage() {
captureType="BLOOD_GLUCOSE"
targetSummarySlug="blood-glucose"
afterChart={<GlucoseClinicalPanel />}
coachPrefill="I haven't logged any blood glucose yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default function InsightsBlutdruckPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
coachPrefill={t("insights.emptyState.bloodPressure.coachPrefill")}
/>
</SubPageShell>
);
+1 -1
View File
@@ -90,7 +90,7 @@ export default function InsightsBmiPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
coachPrefill={t("insights.emptyState.bmi.coachPrefill")}
/>
</SubPageShell>
);
+3 -1
View File
@@ -3,6 +3,7 @@
import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.32 — `/insights/body-temperature`.
@@ -13,6 +14,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* them on separate enum values per the v1.4.30 categorisation overlay.
*/
export default function InsightsBodyTemperaturePage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="BODY_TEMPERATURE"
@@ -24,7 +26,7 @@ export default function InsightsBodyTemperaturePage() {
color="var(--chart-3)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
coachPrefill={t("insights.bodyTemperature.coachPrefill")}
/>
);
}
-1
View File
@@ -25,7 +25,6 @@ export default function InsightsKoerperwasserPage() {
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -25,7 +25,6 @@ export default function InsightsKnochenmassePage() {
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -24,7 +24,6 @@ export default function InsightsBreathingDisturbancesPage() {
unitKey="insights.units.breathingEvents"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -27,7 +27,6 @@ export default function InsightsCardioFitnessPage() {
statIcon={Gauge}
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsCardioRecoveryPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -26,7 +26,6 @@ export default function InsightsTageslichtPage() {
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsDoppelstandphasePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsLaermbelastungPage() {
yAxisUnit="dBA"
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { PersonStanding } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.10.0 — `/insights/falls`.
@@ -11,6 +12,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* better. The series arrives from Apple Health sync — no manual-entry CTA.
*/
export default function InsightsFallsPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="FALL_COUNT"
@@ -23,7 +25,7 @@ export default function InsightsFallsPage() {
unitKey="insights.units.falls"
emptyStateIcon={<PersonStanding className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
coachPrefill={t("insights.falls.coachPrefill")}
/>
);
}
-1
View File
@@ -24,7 +24,6 @@ export default function InsightsFettfreieMassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -24,7 +24,6 @@ export default function InsightsFettmassePage() {
color="var(--chart-5)"
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -25,7 +25,6 @@ export default function InsightsStockwerkePage() {
unitKey="insights.units.flights"
emptyStateIcon={<TrendingUp className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsKopfhoererpegelPage() {
yAxisUnit="dBA"
emptyStateIcon={<Headphones className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
/**
@@ -20,6 +21,7 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
* own, labelled, because the two are not one measure.
*/
export default function InsightsHrvPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
@@ -37,7 +39,7 @@ export default function InsightsHrvPage() {
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
coachPrefill={t("insights.hrv.coachPrefill")}
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// HRV / heart context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
-1
View File
@@ -24,7 +24,6 @@ export default function InsightsMagermassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
/**
* Insights > Medications: the efficacy summary sits below every medication
* card and asks one efficacy read per medication. On entry it holds its slot
* with a sentinel and mounts only when scrolled near, so opening the page
* sends the cards' reads and not the summary's.
*/
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) =>
queryKey[1] === "comprehensive"
? {
data: {
medications: [
{
id: "m1",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
compliance7: 100,
compliance30: 95,
streak: 7,
taken7: 7,
skipped7: 0,
missed7: 0,
},
],
},
isLoading: false,
isError: false,
}
: { data: undefined, isLoading: false, isError: false },
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({ available: false }),
useAiProviderState: () => ({ responseTimeoutMs: null }),
}));
vi.mock("@/components/insights/medication-efficacy-summary", () => ({
MedicationEfficacySummary: () => <div data-slot="efficacy-summary-mounted" />,
}));
vi.mock("@/components/insights/sub-page-shell", () => ({
SubPageShell: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/insights/glp1-substance-curve-summary", () => ({
Glp1SubstanceCurveSummary: () => null,
}));
vi.mock("@/components/insights/metric-target-summary", () => ({
MetricTargetSummary: () => null,
}));
vi.mock("@/components/insights/insight-status-card", () => ({
InsightStatusCard: () => null,
}));
vi.mock("@/components/insights/coach-launch-button", () => ({
CoachLaunchButton: () => null,
}));
vi.mock("@/components/charts/compliance-heatmap", () => ({
ComplianceHeatmap: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import InsightsMedikamentePage from "../page";
describe("/insights/medications on entry", () => {
it("renders the cards and holds the efficacy summary until scrolled near", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightsMedikamentePage />
</I18nProvider>,
);
expect(html).toContain("Ramipril");
expect(html).toContain('data-deferred-section="medication-efficacy"');
expect(html).not.toContain("efficacy-summary-mounted");
});
});
+7 -6
View File
@@ -24,6 +24,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary";
import { DeferUntilNear } from "@/components/insights/defer-until-near";
import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance-curve-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
@@ -321,12 +322,12 @@ export default function InsightsMedikamentePage() {
})}
</div>
{/* v1.28 — compact efficacy summary: reuses the same server-computed
efficacy DTO the detail-page "Wirkung" tab reads (never a second
computation), association-only, linking through to the full view. */}
<MedicationEfficacySummary
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
/>
{/* v1.28 — one efficacy read per medication, below every card: mounted when near. */}
<DeferUntilNear id="medication-efficacy">
<MedicationEfficacySummary
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
/>
</DeferUntilNear>
{/* v1.28.20 — estimated active-substance curve for GLP-1 medications
(additive; renders nothing when the user has no GLP-1 medication). */}
+1 -1
View File
@@ -132,7 +132,7 @@ export default function InsightsMoodPageClient() {
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
coachPrefill={t("insights.emptyState.mood.coachPrefill")}
/>
</SubPageShell>
);
-1
View File
@@ -24,7 +24,6 @@ export default function InsightsMuskelmassePage() {
color="var(--chart-1)"
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -13,6 +13,7 @@ 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 { queryKeys } from "@/lib/query-keys";
import type { ValueBand } from "@/lib/analytics/value-bands";
@@ -37,7 +38,9 @@ function bandsRendered(thresholds: unknown): ValueBand[] {
}
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<InsightsOxygenSaturationPage />
<I18nProvider initialLocale="en">
<InsightsOxygenSaturationPage />
</I18nProvider>
</QueryClientProvider>,
);
const match = /<pre[^>]*>([^<]*)<\/pre>/.exec(html);
+3 -1
View File
@@ -3,6 +3,7 @@
import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands";
import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
@@ -20,6 +21,7 @@ import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
* `/targets`, and the clinical bands otherwise.
*/
export default function InsightsOxygenSaturationPage() {
const { t } = useTranslations();
const ownRange = useOwnTargetRange("OXYGEN_SATURATION");
return (
<HealthKitMetricPage
@@ -38,7 +40,7 @@ export default function InsightsOxygenSaturationPage() {
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
captureType="OXYGEN_SATURATION"
coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
coachPrefill={t("insights.oxygenSaturation.coachPrefill")}
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsPulswellengeschwindigkeitPage() {
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any pulse wave velocity yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ export default function InsightsPulsPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
coachPrefill={t("insights.emptyState.pulse.coachPrefill")}
/>
</SubPageShell>
);
@@ -25,7 +25,6 @@ export default function InsightsAtemfrequenzPage() {
unitKey="insights.units.respiratoryRate"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -25,7 +25,6 @@ export default function InsightsSixMinuteWalkPage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -32,7 +32,6 @@ export default function InsightsHauttemperaturPage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?"
afterChart={
<ConnectedDeviceScoreTile
type="BODY_TEMPERATURE_DEVIATION"
+1 -1
View File
@@ -70,7 +70,7 @@ export default function InsightsSchlafPage() {
</Link>
</Button>
}
coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
coachPrefill={t("insights.emptyState.sleep.coachPrefill")}
/>
</SubPageShell>
);
@@ -25,7 +25,6 @@ export default function InsightsStairAscentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -25,7 +25,6 @@ export default function InsightsStairDescentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -26,7 +26,6 @@ export default function InsightsSchrittlaengePage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function InsightsStepsPage() {
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
captureType="ACTIVITY_STEPS"
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
coachPrefill={t("insights.steps.coachPrefill")}
afterChart={
calendarVisible ? (
<CrossLinkCard
-1
View File
@@ -25,7 +25,6 @@ export default function InsightsGefaessalterPage() {
unitKey="insights.units.years"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -26,7 +26,6 @@ export default function InsightsViszeralfettPage() {
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsGangasymmetriePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -24,7 +24,6 @@ export default function InsightsGehstreckePage() {
color="var(--chart-2)"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsGehpulsPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
-1
View File
@@ -24,7 +24,6 @@ export default function InsightsGehgeschwindigkeitPage() {
color="var(--chart-2)"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,6 @@ export default function InsightsGangstabilitaetPage() {
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -122,7 +122,7 @@ export default function InsightsGewichtPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
coachPrefill={t("insights.emptyState.weight.coachPrefill")}
/>
</SubPageShell>
);
+1 -1
View File
@@ -142,7 +142,7 @@ export default function InsightsWorkoutsPageClient() {
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
coachPrefill={t("insights.workouts.emptyState.coachPrefill")}
/>
) : (
<>
@@ -24,7 +24,6 @@ export default function InsightsWristTemperaturePage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+16 -2
View File
@@ -8,6 +8,7 @@ import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
@@ -86,7 +87,12 @@ export default async function RootLayout({
? ((await headers()).get("x-nonce") ?? undefined)
: undefined;
const initialLocale = await resolveInitialLocale();
// The monitoring switches ride the document render instead of a request
// from every page load; the key-mismatch page reads no data.
const [initialLocale, monitoring] = await Promise.all([
resolveInitialLocale(),
isKeyMismatch() ? null : getPublicMonitoringSettings(),
]);
return (
<html lang={initialLocale} suppressHydrationWarning>
@@ -139,7 +145,15 @@ export default async function RootLayout({
<KeyMismatchPage locale={initialLocale} />
) : (
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<MonitoringBootstrap
settings={
monitoring && {
umamiEnabled: monitoring.umamiEnabled,
umamiWebsiteId: monitoring.umamiWebsiteId,
glitchtipEnabled: monitoring.glitchtipEnabled,
}
}
/>
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
block mutations. Resolve it here (server component) and
+13
View File
@@ -102,6 +102,19 @@ export default async function DashboardPage() {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
// The mood chart's series rides the mood tile (same engine as
// `/api/mood/analytics`); seeded with readings only, where the tile's
// summary is the route's.
const mood = (
wireBody.tiles as
{ mood?: { entries: unknown[]; summary: unknown } } | undefined
)?.mood;
if (mood?.summary && mood.entries.length > 0) {
queryClient.setQueryData(queryKeys.moodAnalytics(), {
entries: mood.entries,
summary: mood.summary,
});
}
// Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives
// its chart-type list from analytics queries the RSC did not run, so there
@@ -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);
});
}
});
+38
View File
@@ -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">
&nbsp;
</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>();
+2 -4
View File
@@ -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(
"",
);
});
});
+15 -37
View File
@@ -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 (
+38 -7
View File
@@ -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]);
@@ -0,0 +1,117 @@
/**
* The "Coach read" strip reads its pattern line from one cached scan per
* record and language, not one scan per metric page: the scan covers every
* channel and does not depend on the metric. A failed scan is not cached, a
* write that sweeps the record's analytics cells forces a new one, and a
* switched-off module reads under its own cell.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const readCoachCorrelations = vi.fn();
const resolveModuleMap = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: async () => [],
findFirst: async () => null,
},
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
}));
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/insights/derived/baseline")>()),
loadBaselineProfile: async () => ({ ageYears: 40, sex: "MALE" }),
// The band is not what this file is about: no band, the strip says
// "still learning" and only the pattern line varies.
computeVitalsBaseline: async () => ({ status: "insufficient" }),
}));
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string, locale: string) =>
readCoachCorrelations(userId, locale),
}));
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
const SLEEP_DRIVER = {
behaviour: "symptom severity",
outcome: "sleep duration",
direction: "lower" as const,
lagDays: 1,
n: 40,
r: -0.6,
note: "A pattern to watch, not a cause.",
};
const PULSE_DRIVER = { ...SLEEP_DRIVER, outcome: "pulse" };
const strip = (type: "SLEEP_DURATION" | "PULSE", locale: "en" | "de" = "en") =>
buildCoachReadStrip("u1", type, locale, { tz: "UTC" });
beforeEach(() => {
__resetAllCachesForTests();
readCoachCorrelations.mockReset().mockResolvedValue({
present: true,
drivers: [SLEEP_DRIVER, PULSE_DRIVER],
});
resolveModuleMap.mockReset().mockResolvedValue({});
});
describe("coach read — the pattern scan behind the strip", () => {
it("scans once for two metric pages and picks each page's own driver", async () => {
const sleep = await strip("SLEEP_DURATION");
const pulse = await strip("PULSE");
expect(readCoachCorrelations).toHaveBeenCalledTimes(1);
expect(sleep.driver?.outcome).toBe("sleep duration");
expect(pulse.driver?.outcome).toBe("pulse");
});
it("scans again per language, since the note is a finished sentence", async () => {
await strip("SLEEP_DURATION", "en");
await strip("SLEEP_DURATION", "de");
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
expect(readCoachCorrelations).toHaveBeenLastCalledWith("u1", "de");
});
it("does not keep a failed scan", async () => {
readCoachCorrelations.mockResolvedValueOnce({
present: false,
reason: "retrieval_failed",
});
const failed = await strip("SLEEP_DURATION");
const retried = await strip("SLEEP_DURATION");
expect(failed.driver).toBeNull();
expect(retried.driver?.outcome).toBe("sleep duration");
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
});
it("scans again after a write sweeps the record's analytics cells", async () => {
await strip("SLEEP_DURATION");
invalidateUserMood("u1");
await strip("SLEEP_DURATION");
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
});
it("reads a switched-off module under its own cell", async () => {
await strip("SLEEP_DURATION");
resolveModuleMap.mockResolvedValue({ insights: false });
readCoachCorrelations.mockResolvedValue({
present: false,
reason: "module_disabled",
});
const off = await strip("SLEEP_DURATION");
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
expect(off.driver).toBeNull();
});
});
+40 -10
View File
@@ -25,8 +25,12 @@
* component and the unit test can share them without pulling Prisma.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { prisma } from "@/lib/db";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { MODULE_KEYS } from "@/lib/modules/registry";
import {
loadBaselineProfile,
computeVitalsBaseline,
@@ -42,7 +46,10 @@ import {
isFromToday,
} from "@/lib/insights/measurement-freshness";
import { userDayKey } from "@/lib/tz/format";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import {
readCoachCorrelations,
type CoachCorrelationDriver,
} from "@/lib/ai/coach/tools/correlations-read";
import {
humaniseType,
placeAgainstBand,
@@ -88,6 +95,34 @@ function latestDay(
};
}
/**
* The discovered drivers, through the analytics cache. The scan covers every
* channel over 180 days and does not depend on the metric, so without the cell
* each metric page paid the whole scan again for the one line it picks. Same
* cell contract as `/api/insights/correlations`: measurement, mood, medication,
* illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and
* the switched-off modules are part of the key. A failed read is not cached.
*/
async function readDrivers(
userId: string,
locale: Locale,
): Promise<CoachCorrelationDriver[] | null> {
const modules = await resolveModuleMap(userId);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
return cachedSwr(
caches.analytics as ServerCache<CoachCorrelationDriver[] | null>,
`${userId}|coach-read-drivers|${locale}|off:${modulesOff.join(",")}`,
async () => {
const result = await readCoachCorrelations(userId, locale);
if (result.reason === "retrieval_failed") {
throw new Error("coach read correlations failed");
}
return result.drivers ?? null;
},
annotate,
);
}
/** Trailing window the band engine reads; the day-mean placement matches it. */
const BAND_WINDOW_DAYS = 30;
@@ -165,13 +200,11 @@ export async function buildCoachReadStrip(
const now = opts.now ?? new Date();
const profile = await loadBaselineProfile(prisma, userId);
const [baselineDerived, latest, correlations] = await Promise.all([
const [baselineDerived, latest, drivers] = await Promise.all([
computeVitalsBaseline(userId, profile, { type, now }),
readLatestValue(userId, type),
// Line 2 is best-effort: a correlation failure must never sink the band.
readCoachCorrelations(userId, locale).catch(
() => ({ present: false }) as const,
),
readDrivers(userId, locale).catch(() => null),
]);
let baseline: CoachReadBaseline | null = null;
@@ -207,11 +240,8 @@ export async function buildCoachReadStrip(
}
const driver = ((): CoachReadDriver | null => {
if (!("drivers" in correlations) || !correlations.drivers) return null;
const picked = pickDriverForMetric(
correlations.drivers,
humaniseType(type),
);
if (!drivers) return null;
const picked = pickDriverForMetric(drivers, humaniseType(type));
if (!picked) return null;
return {
note: picked.note,
@@ -143,6 +143,46 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => {
await (opts.queryFn as () => Promise<unknown>)();
expect(setQueryDataMock).not.toHaveBeenCalled();
});
it("seeds the mood chart's series from the mood tile, so the chart asks for nothing", async () => {
const entries = [{ date: "2026-11-10", score: 4, samples: 1 }];
const summary = { count: 1, latest: 4 };
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
envelopeResponse({
layout: { widgets: [] },
tiles: { mood: { entries, summary } },
}),
),
);
getQueryDataMock.mockReturnValue(undefined);
useDashboardSnapshot();
await (lastOpts().queryFn as () => Promise<unknown>)();
expect(setQueryDataMock).toHaveBeenCalledWith(queryKeys.moodAnalytics(), {
entries,
summary,
});
});
it("leaves the mood chart to its own read when the tile has no readings", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
envelopeResponse({
layout: { widgets: [] },
tiles: { mood: { entries: [], summary: null } },
}),
),
);
getQueryDataMock.mockReturnValue(undefined);
useDashboardSnapshot();
await (lastOpts().queryFn as () => Promise<unknown>)();
expect(setQueryDataMock).not.toHaveBeenCalledWith(
queryKeys.moodAnalytics(),
expect.anything(),
);
});
});
describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
+15
View File
@@ -194,6 +194,21 @@ function makeSnapshotQueryFn(queryClient: QueryClient) {
if (!queryClient.getQueryData(queryKeys.dashboardWidgets())) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), snap.layout);
}
// The mood tile carries the series `/api/mood/analytics` answers (one
// engine, `buildMoodDailySeries`), which is what the mood chart reads.
// Same seed-when-empty rule, and only with readings, the one case where
// the tile's summary is the route's.
const mood = snap.tiles?.mood;
if (
mood?.summary &&
mood.entries.length > 0 &&
!queryClient.getQueryData(queryKeys.moodAnalytics())
) {
queryClient.setQueryData(queryKeys.moodAnalytics(), {
entries: mood.entries,
summary: mood.summary,
});
}
return snap;
};
}