Merge v1.8.0 Insights release into main

# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 23:00:08 +02:00
commit 869f13612a
115 files changed
+5736 -2737

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
* Returns a chronological merge of every GLP-1-relevant event the user
* has logged: dose changes, injections (with site if recorded),
* inventory events, and side-effect-tagged mood entries from the
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
* trailing 90 days. Used by /insights/medications' TherapyTimeline
* component. Web-only users without a GLP-1 medication get
* `hasGlp1: false` so the component hides cleanly.
*/
@@ -122,7 +122,7 @@ describe("GET /api/insights/layout", () => {
version: 1,
tiles: [
{ id: "overview", visible: false, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "blood-pressure", visible: true, order: 1 },
],
},
} as never);
@@ -141,12 +141,42 @@ describe("GET /api/insights/layout", () => {
order: 0,
});
expect(body.data.tiles[1]).toEqual({
id: "blutdruck",
id: "blood-pressure",
visible: true,
order: 1,
});
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
});
it("normalises a legacy German tile id stored by a ≤ v1.7.x client to its canonical English id on read", async () => {
// A layout persisted before the v1.8.0 rename (or by an iOS client
// still speaking the German ids). The resolver must surface the
// canonical English id so the GET contract is current-build clean
// without forcing a re-PUT.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "stimmung", visible: false, order: 2 },
],
},
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
};
const ids = body.data.tiles.map((t) => t.id);
// Legacy ids collapse to their English replacement…
expect(ids).toContain("blood-pressure");
expect(ids).toContain("mood");
// …and the German originals never leak back out.
expect(ids).not.toContain("blutdruck");
expect(ids).not.toContain("stimmung");
});
});
describe("PUT /api/insights/layout — happy path", () => {
@@ -157,8 +187,8 @@ describe("PUT /api/insights/layout — happy path", () => {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "puls", visible: false, order: 2 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: false, order: 2 },
],
};
const res = await callPut(makeReq(payload));
@@ -168,7 +198,7 @@ describe("PUT /api/insights/layout — happy path", () => {
};
expect(body.data.tiles[0]?.id).toBe("overview");
expect(body.data.tiles[0]?.order).toBe(0);
expect(body.data.tiles[2]?.id).toBe("puls");
expect(body.data.tiles[2]?.id).toBe("pulse");
expect(body.data.tiles[2]?.visible).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -179,6 +209,47 @@ describe("PUT /api/insights/layout — happy path", () => {
expect(updateCall.where.id).toBe("user-1");
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
});
it("accepts a body carrying legacy German tile ids and persists the canonical English ones", async () => {
// Non-breaking iOS contract: a client still sending the pre-v1.8.0
// German ids must NOT 422. The route validates the legacy ids and
// `serializeInsightsLayout` normalises them to English before the
// row persists, so the stored blob is always canonical.
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "medikamente", visible: true, order: 2 },
],
};
const res = await callPut(makeReq(payload));
// No 422 — the legacy ids pass validation.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const returnedIds = body.data.tiles.map((t) => t.id);
expect(returnedIds).toContain("blood-pressure");
expect(returnedIds).toContain("medications");
expect(returnedIds).not.toContain("blutdruck");
expect(returnedIds).not.toContain("medikamente");
// The persisted blob is canonical too.
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
};
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
(t) => t.id,
);
expect(persistedIds).toContain("blood-pressure");
expect(persistedIds).toContain("medications");
expect(persistedIds).not.toContain("blutdruck");
expect(persistedIds).not.toContain("medikamente");
});
});
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
+13 -6
View File
@@ -27,7 +27,7 @@ import {
resolveInsightsLayout,
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
INSIGHTS_TILE_IDS,
ACCEPTED_INSIGHTS_TILE_IDS,
type InsightsLayout,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
@@ -38,11 +38,18 @@ import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default
// layout cannot drift — the same root-cause class that produced the
// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was
// added to the layout but not to the validation enum.
const tileIdEnum = z.enum(INSIGHTS_TILE_IDS);
// Derive the enum from `ACCEPTED_INSIGHTS_TILE_IDS` so the schema + the
// default layout cannot drift — the same root-cause class that produced
// the v1.4.16 A5 silent-422 on dashboard widgets when `achievements`
// was added to the layout but not to the validation enum.
//
// v1.8.0 — the accepted set is canonical English ids PLUS the legacy
// German aliases, so an iOS client still PUTting `blutdruck` / `puls` /
// … passes validation rather than tripping a 422 on the rename.
// `serializeInsightsLayout` normalises any legacy id onto its canonical
// English replacement before the row persists, so the stored blob is
// always canonical regardless of which id the client sent.
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
const layoutSchema = z.object({
version: z.literal(1),
+20
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -172,6 +173,18 @@ export const PUT = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this edit
// dirties so the next mount / nightly warm pass regenerates against
// the new value. An edit can re-type a row, so invalidate both the
// old and the new type's scopes. Fire-and-forget: never blocks the
// user's edit.
invalidateStatusInsightsForTypes(user.id, [
existing.type,
measurement.type,
]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement);
},
);
@@ -238,6 +251,13 @@ export const DELETE = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the user's delete.
invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess({ deleted: true });
},
);
+12
View File
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data instead of serving stale text.
// Fire-and-forget: never a blocker on the user's ingest.
invalidateStatusInsightsForTypes(
user.id,
insertedKeys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -35,6 +35,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -152,6 +153,17 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deleted
// types dirty so the next mount / nightly warm pass regenerates
// against the reconciled history. Fire-and-forget: never blocks the
// iOS reconciliation.
invalidateStatusInsightsForTypes(
user.id,
affectedRows.map((row) => row.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
}
return apiSuccess({ deletedCount: result.count });
+18
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data. Fire-and-forget: never blocks ingest.
invalidateStatusInsightsForTypes(
user.id,
keys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this type
// dirties so the next mount / nightly warm pass regenerates against
// the new row. Fire-and-forget: never blocks the user's write.
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement, 201);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.8.0 — explainer-metric key guard.
*
* `<MetricExplainer>` builds its i18n lookups from a template literal:
*
* t(`insights.subPage.explainer.${metric}Title`)
* t(`insights.subPage.explainer.${metric}Body`)
*
* The static `i18n-call-site-coverage.test.ts` only sees literal
* `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve
* to a missing key and paint the raw key string into the popover — with
* no CI signal. This test closes that gap the same way the
* Health-Score provenance key tests pin their dynamic keys: it walks
* every `explainerMetric="…"` value used under `src/app/insights/**`
* (plus the shared `healthkit-metric-page` scaffold) and asserts that
* both the `<value>Title` and `<value>Body` leaves exist in
* `messages/en.json`. `i18n-locale-integrity.test.ts` then propagates
* the EN guarantee across the other five locales.
*/
const ROOT = join(__dirname, "../../../..");
const INSIGHTS_DIR = join(ROOT, "src/app/insights");
const HEALTHKIT_PAGE = join(
ROOT,
"src/components/insights/healthkit-metric-page.tsx",
);
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) {
if (name === "__tests__") continue;
walk(p, out);
} else if (name.endsWith(".tsx")) {
out.push(p);
}
}
return out;
}
interface PropSite {
value: string;
file: string;
line: number;
}
// Match `explainerMetric="someValue"` — a string-literal prop. The
// component contract only accepts a static string, so a dynamic
// `explainerMetric={expr}` would be a different (and unverifiable)
// shape; none exist in the tree and we assert that below.
const LITERAL_PATTERN = /explainerMetric="([a-zA-Z][a-zA-Z0-9]*)"/g;
const DYNAMIC_PATTERN = /explainerMetric=\{/;
function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } {
const files = [...walk(INSIGHTS_DIR), HEALTHKIT_PAGE];
const sites: PropSite[] = [];
const dynamic: PropSite[] = [];
for (const file of files) {
const lines = readFileSync(file, "utf8").split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// The scaffold declares + forwards the prop; ignore the type
// declaration + the pass-through (`explainerMetric={explainerMetric}`)
// — those are not call sites that pin a concrete key.
if (file === HEALTHKIT_PAGE) continue;
let m: RegExpExecArray | null;
LITERAL_PATTERN.lastIndex = 0;
while ((m = LITERAL_PATTERN.exec(line)) !== null) {
sites.push({ value: m[1], file: file.slice(ROOT.length + 1), line: i + 1 });
}
if (DYNAMIC_PATTERN.test(line)) {
dynamic.push({ value: line.trim(), file: file.slice(ROOT.length + 1), line: i + 1 });
}
}
}
return { sites, dynamic };
}
function hasLeaf(obj: unknown, dotted: string): boolean {
let cur: unknown = obj;
for (const part of dotted.split(".")) {
if (cur && typeof cur === "object" && part in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[part];
} else {
return false;
}
}
return typeof cur === "string";
}
describe("insights explainerMetric key coverage", () => {
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
const { sites, dynamic } = collectSites();
it("finds explainerMetric props across the insights pages", () => {
// Guards against the walker silently matching nothing (e.g. a refactor
// that renames the prop). The category surface carries dozens of pages.
expect(sites.length).toBeGreaterThan(20);
});
it("uses only string-literal explainerMetric props (no dynamic expressions)", () => {
// A dynamic value can't be statically verified against the bundle, so
// the contract is literal-only. If this ever fails, either inline the
// literal or extend this guard to resolve the expression.
expect(dynamic).toEqual([]);
});
it("every explainerMetric value resolves a Title + Body leaf in messages/en.json", () => {
const missing: string[] = [];
for (const site of sites) {
const titleKey = `insights.subPage.explainer.${site.value}Title`;
const bodyKey = `insights.subPage.explainer.${site.value}Body`;
if (!hasLeaf(en, titleKey)) {
missing.push(` ❌ ${titleKey} (${site.file}:${site.line})`);
}
if (!hasLeaf(en, bodyKey)) {
missing.push(` ❌ ${bodyKey} (${site.file}:${site.line})`);
}
}
if (missing.length > 0) {
throw new Error(
`Found ${missing.length} explainerMetric key(s) missing from messages/en.json:\n${missing.join("\n")}`,
);
}
});
});
@@ -5,7 +5,7 @@ import { Flame } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/aktive-energie`.
* v1.4.32 — `/insights/active-energy`.
*
* Active-energy-burned sub-page. The MeasurementType is cumulative
* (kcal per day, per R-A Option A); the v1.4.30 server-side daily-
@@ -20,6 +20,7 @@ export default function InsightsActiveEnergyPage() {
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
explainerMetric="activeEnergy"
color="#ffb86c"
unit="kcal"
yAxisUnit="kcal"
@@ -5,7 +5,7 @@ import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermereignisse`.
* v1.7.0 — `/insights/audio-events`.
*
* AUDIO_EXPOSURE_EVENT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsLaermereignissePage() {
insightMetric="AUDIO_EXPOSURE_EVENT"
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
explainerMetric="audioEvents"
color="#ff79c6"
unit=""
yAxisUnit=""
@@ -5,7 +5,7 @@ import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/blutzucker`.
* v1.7.0 — `/insights/blood-glucose`.
*
* BLOOD_GLUCOSE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsBlutzuckerPage() {
insightMetric="BLOOD_GLUCOSE"
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
explainerMetric="bloodGlucose"
color="#ff79c6"
unit="mg/dL"
yAxisUnit="mg/dL"
@@ -13,11 +13,12 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
* v1.4.25 W4 — `/insights/blood-pressure`.
*
* Routed Blood-Pressure sub-page. The mother page hosts the overview
* (hero + briefing + advisor); this page concentrates on BP-only depth:
@@ -51,6 +52,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
>
<MetricEmptyState
icon={<HeartPulse className="size-6" />}
@@ -98,6 +100,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
>
<HealthChartDynamic
chartKey="bp"
@@ -111,6 +114,8 @@ export default function InsightsBlutdruckPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="blood-pressure" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
+6
View File
@@ -14,6 +14,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -58,6 +59,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<MetricEmptyState
icon={<Ruler className="size-6" />}
@@ -81,6 +83,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<EmptyState
icon={<Ruler className="size-6" />}
@@ -106,6 +109,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<HealthChartDynamic
chartKey="bmi"
@@ -119,6 +123,8 @@ export default function InsightsBmiPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="bmi" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/koerpertemperatur`.
* v1.4.32 — `/insights/body-temperature`.
*
* Body-temperature sub-page. Canonical storage is Celsius. Distinct
* from skin temperature (Withings ScanWatch dermal reading) — surface
@@ -19,6 +19,7 @@ export default function InsightsBodyTemperaturePage() {
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="#ff79c6"
unit="°C"
yAxisUnit="°C"
@@ -5,7 +5,7 @@ import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/koerperwasser`.
* v1.7.0 — `/insights/body-water`.
*
* TOTAL_BODY_WATER sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKoerperwasserPage() {
insightMetric="TOTAL_BODY_WATER"
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="#8be9fd"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Bone } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/knochenmasse`.
* v1.7.0 — `/insights/bone-mass`.
*
* BONE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKnochenmassePage() {
insightMetric="BONE_MASS"
chartKey="boneMass"
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Sun } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/tageslicht`.
* v1.7.0 — `/insights/daylight`.
*
* TIME_IN_DAYLIGHT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsTageslichtPage() {
insightMetric="TIME_IN_DAYLIGHT"
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
explainerMetric="daylight"
color="#50fa7b"
unit="min"
yAxisUnit="min"
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/doppelstandphase`.
* v1.7.0 — `/insights/double-support-time`.
*
* WALKING_DOUBLE_SUPPORT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsDoppelstandphasePage() {
insightMetric="WALKING_DOUBLE_SUPPORT"
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
explainerMetric="doubleSupportTime"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermbelastung`.
* v1.7.0 — `/insights/environmental-audio`.
*
* AUDIO_EXPOSURE_ENV sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsLaermbelastungPage() {
insightMetric="AUDIO_EXPOSURE_ENV"
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
explainerMetric="environmentalAudio"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
@@ -5,7 +5,7 @@ import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettfreie-masse`.
* v1.7.0 — `/insights/fat-free-mass`.
*
* FAT_FREE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsFettfreieMassePage() {
insightMetric="FAT_FREE_MASS"
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettmasse`.
* v1.7.0 — `/insights/fat-mass`.
*
* FAT_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsFettmassePage() {
insightMetric="FAT_MASS"
chartKey="fatMass"
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { TrendingUp } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/stockwerke`.
* v1.7.0 — `/insights/flights-climbed`.
*
* FLIGHTS_CLIMBED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsStockwerkePage() {
insightMetric="FLIGHTS_CLIMBED"
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
explainerMetric="flightsClimbed"
color="#50fa7b"
unit="flights"
yAxisUnit="flights"
@@ -5,7 +5,7 @@ import { Headphones } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/kopfhoererpegel`.
* v1.7.0 — `/insights/headphone-audio`.
*
* AUDIO_EXPOSURE_HEADPHONE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKopfhoererpegelPage() {
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
explainerMetric="headphoneAudio"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
+1
View File
@@ -20,6 +20,7 @@ export default function InsightsHrvPage() {
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
explainerMetric="hrv"
color="#bd93f9"
unit="ms"
yAxisUnit="ms"
@@ -5,7 +5,7 @@ import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/magermasse`.
* v1.7.0 — `/insights/lean-body-mass`.
*
* LEAN_BODY_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsMagermassePage() {
insightMetric="LEAN_BODY_MASS"
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -15,12 +15,13 @@ import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
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 { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
/**
* v1.4.25 W4 — `/insights/medikamente`.
* v1.4.25 W4 — `/insights/medications`.
*
* Routed Medication-Compliance sub-page. Mirrors the per-medication
* grid from the v1.4.24 mother page (compliance bars + heatmap +
@@ -111,7 +112,10 @@ export default function InsightsMedikamentePage() {
if (isComprehensiveLoading) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medications"
>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
@@ -124,7 +128,10 @@ export default function InsightsMedikamentePage() {
// gate reads `medications.length > 0`. CTA targets `/medications`
// (the dedicated medication-management surface).
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medications"
>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.emptyState.medication.title")}
@@ -149,6 +156,7 @@ export default function InsightsMedikamentePage() {
<SubPageShell
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
explainerMetric="medications"
>
<div
className={
@@ -158,7 +166,7 @@ export default function InsightsMedikamentePage() {
{medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
// UI-H3 — route every medication-list row (medications page
// + insights/medikamente) through `<MedicationCardHeader>`
// + insights/medications) through `<MedicationCardHeader>`
// so the surface stays one shape: `{name} {dose}` on line 1
// + outline category badge on line 2. The streak chip rides
// the `stateBadges` slot so a strong streak still surfaces.
@@ -235,6 +243,8 @@ export default function InsightsMedikamentePage() {
})}
</div>
<MetricTargetSummary slug="medications" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
@@ -15,10 +15,11 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/stimmung`.
* v1.4.25 W4 — `/insights/mood`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
@@ -75,6 +76,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
@@ -95,6 +97,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MoodChart
chartKey="mood"
@@ -102,6 +105,8 @@ export default function InsightsStimmungPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="mood" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Dumbbell } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/muskelmasse`.
* v1.7.0 — `/insights/muscle-mass`.
*
* MUSCLE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsMuskelmassePage() {
insightMetric="MUSCLE_MASS"
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/sauerstoff`.
* v1.4.32 — `/insights/oxygen`.
*
* Oxygen-saturation (SpO₂) sub-page. Canonical storage is percent
* (0..100); the chart axis runs 85..100 because clinical normal is
@@ -21,6 +21,7 @@ export default function InsightsOxygenSaturationPage() {
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
explainerMetric="oxygenSaturation"
color="#8be9fd"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/pulswellengeschwindigkeit`.
* v1.7.0 — `/insights/pulse-wave-velocity`.
*
* PULSE_WAVE_VELOCITY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
insightMetric="PULSE_WAVE_VELOCITY"
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
explainerMetric="pulseWaveVelocity"
color="#ff5555"
unit="m/s"
yAxisUnit="m/s"
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import {
@@ -21,7 +22,7 @@ import {
} from "@/lib/analytics/pulse-targets";
/**
* v1.4.25 W4 — `/insights/puls`.
* v1.4.25 W4 — `/insights/pulse`.
*
* Routed Pulse sub-page. Renders the pulse chart with the personalized
* Karvonen-derived target band plus the per-section AI assessment.
@@ -61,6 +62,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
>
<MetricEmptyState
icon={<Heart className="size-6" />}
@@ -111,6 +113,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
>
<HealthChartDynamic
chartKey="pulse"
@@ -123,6 +126,8 @@ export default function InsightsPulsPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="pulse" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/atemfrequenz`.
* v1.7.0 — `/insights/respiratory-rate`.
*
* RESPIRATORY_RATE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsAtemfrequenzPage() {
insightMetric="RESPIRATORY_RATE"
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
explainerMetric="respiratoryRate"
color="#8be9fd"
unit="breaths/min"
yAxisUnit="breaths/min"
@@ -5,9 +5,9 @@ import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/ruhepuls`.
* v1.4.32 — `/insights/resting-pulse`.
*
* Resting-heart-rate sub-page. Distinct from `/insights/puls` — Apple
* Resting-heart-rate sub-page. Distinct from `/insights/pulse` — Apple
* Health splits the steady-state daily `restingHeartRate` value from
* the spot `PULSE` samples logged manually or by Withings. The split
* here mirrors that distinction so the user has a "daily floor"
@@ -20,6 +20,7 @@ export default function InsightsRestingHrPage() {
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
explainerMetric="restingHr"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
@@ -5,7 +5,7 @@ import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/hauttemperatur`.
* v1.7.0 — `/insights/skin-temperature`.
*
* SKIN_TEMPERATURE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsHauttemperaturPage() {
insightMetric="SKIN_TEMPERATURE"
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="#ffb86c"
unit="°C"
yAxisUnit="°C"
@@ -8,11 +8,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4c — `/insights/schlaf`.
* v1.4.25 W4c — `/insights/sleep`.
*
* The Sleep sub-page surfaces the per-stage breakdown + duration trend
* the v1.4.23 schema gained but never rendered. All charts live inside
@@ -37,6 +38,7 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="sleep"
>
<MetricEmptyState
icon={<Moon className="size-6" />}
@@ -59,9 +61,12 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="sleep"
>
<SleepOverview />
<MetricTargetSummary slug="sleep" />
{/*
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
mount `<InsightStatusCard>` underneath the chart so the user
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/schrittlaenge`.
* v1.7.0 — `/insights/step-length`.
*
* WALKING_STEP_LENGTH sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsSchrittlaengePage() {
insightMetric="WALKING_STEP_LENGTH"
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
explainerMetric="stepLength"
color="#50fa7b"
unit="m"
yAxisUnit="m"
@@ -5,7 +5,7 @@ import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gefaessalter`.
* v1.7.0 — `/insights/vascular-age`.
*
* VASCULAR_AGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGefaessalterPage() {
insightMetric="VASCULAR_AGE"
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
explainerMetric="vascularAge"
color="#ff79c6"
unit="years"
yAxisUnit="years"
@@ -5,7 +5,7 @@ import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/viszeralfett`.
* v1.7.0 — `/insights/visceral-fat`.
*
* VISCERAL_FAT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsViszeralfettPage() {
insightMetric="VISCERAL_FAT"
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
explainerMetric="visceralFat"
color="#ffb86c"
unit=""
yAxisUnit=""
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangasymmetrie`.
* v1.7.0 — `/insights/walking-asymmetry`.
*
* WALKING_ASYMMETRY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGangasymmetriePage() {
insightMetric="WALKING_ASYMMETRY"
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
explainerMetric="walkingAsymmetry"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehstrecke`.
* v1.7.0 — `/insights/walking-distance`.
*
* WALKING_RUNNING_DISTANCE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehstreckePage() {
insightMetric="WALKING_RUNNING_DISTANCE"
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="#50fa7b"
unit="km"
yAxisUnit="km"
@@ -5,7 +5,7 @@ import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehpuls`.
* v1.7.0 — `/insights/walking-heart-rate`.
*
* WALKING_HEART_RATE_AVERAGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehpulsPage() {
insightMetric="WALKING_HEART_RATE_AVERAGE"
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
explainerMetric="walkingHeartRate"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
@@ -5,7 +5,7 @@ import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehgeschwindigkeit`.
* v1.7.0 — `/insights/walking-speed`.
*
* WALKING_SPEED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehgeschwindigkeitPage() {
insightMetric="WALKING_SPEED"
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="#50fa7b"
unit="km/h"
yAxisUnit="km/h"
@@ -5,7 +5,7 @@ import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangstabilitaet`.
* v1.7.0 — `/insights/walking-steadiness`.
*
* WALKING_STEADINESS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGangstabilitaetPage() {
insightMetric="WALKING_STEADINESS"
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
explainerMetric="walkingSteadiness"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -13,11 +13,12 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
/**
* v1.4.25 W4 — `/insights/gewicht`.
* v1.4.25 W4 — `/insights/weight`.
*
* Routed Weight sub-page. Renders the weight chart with the user's
* height-derived green/orange/red bands plus the per-section AI
@@ -43,6 +44,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
>
<MetricEmptyState
icon={<Scale className="size-6" />}
@@ -72,6 +74,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
>
<HealthChartDynamic
chartKey="weight"
@@ -84,6 +87,8 @@ export default function InsightsGewichtPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="weight" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
+1
View File
@@ -35,6 +35,7 @@ export default function InsightsWorkoutsPage() {
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
explainerMetric="workouts"
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
@@ -0,0 +1,151 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { MetricExplainer } from "../metric-explainer";
/**
* v1.8.0 — `<MetricExplainer>` unit tests.
*
* The explainer is the `?` glyph next to a metric sub-page heading that
* opens a static "What is X?" read on tap / Enter / Space. Tests run
* through SSR so they don't depend on a browser surface: the trigger
* button is always present, the popover / sheet body mounts only after
* the user activates it. The load-bearing constraints pinned here are
* the a11y contract (real button, labelled, disclosure semantics, 44 px
* hit surface) and that every locale ships the title + body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Probe that resolves the explainer keys through the same `t()` calls
* the component uses, so a dropped locale key fails here at the call
* site instead of silently rendering the literal key path on the page.
*/
function KeyProbe({ metric }: { metric: string }) {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-title">
{t(`insights.subPage.explainer.${metric}Title`)}
</span>
<span data-slot="probe-body">
{t(`insights.subPage.explainer.${metric}Body`)}
</span>
</div>
);
}
// v1.8.0 — English key segments (ADR-0001). Every routed insights
// category page now passes one of these; the explainer copy ships in
// all six locales.
const METRICS = [
"bloodPressure",
"pulse",
"weight",
"bmi",
"sleep",
"mood",
"medications",
"restingHr",
"hrv",
"oxygenSaturation",
"bodyTemperature",
"activeEnergy",
"workouts",
"respiratoryRate",
"bodyWater",
"boneMass",
"fatFreeMass",
"fatMass",
"muscleMass",
"visceralFat",
"leanBodyMass",
"flightsClimbed",
"walkingDistance",
"walkingSteadiness",
"walkingHeartRate",
"walkingAsymmetry",
"doubleSupportTime",
"stepLength",
"walkingSpeed",
"pulseWaveVelocity",
"vascularAge",
"environmentalAudio",
"headphoneAudio",
"audioEvents",
"daylight",
"bloodGlucose",
"skinTemperature",
] as const;
describe("<MetricExplainer>", () => {
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
const html = ssr(<MetricExplainer metric="bloodPressure" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 14 px but the click surface inflates to 44 px via
// `min-h-11 min-w-11`; the surrounding heading row keeps its stride
// because `-my-3 -mx-2` swallows the extra reach. Pinned so a future
// refactor can't collapse the hit surface back to the visual chip.
const html = ssr(<MetricExplainer metric="pulse" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads disclosure semantics onto the trigger button", () => {
const html = ssr(<MetricExplainer metric="hrv" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default: the body lives in a portal that mounts after
// the user activates the trigger. Pinning the negative keeps a
// future "open on render" refactor from leaking copy into the
// static markup.
const html = ssr(<MetricExplainer metric="weight" />);
expect(html).not.toContain('data-slot="metric-explainer-body"');
expect(html).not.toContain('data-slot="metric-explainer-title"');
});
it("localises the trigger label in German", () => {
const html = ssr(<MetricExplainer metric="sleep" />, "de");
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"');
});
it("ships the title + body copy for every category metric in every locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const metric of METRICS) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<KeyProbe metric={metric} />
</I18nProvider>,
);
// A missing key surfaces as the literal `insights.subPage…`
// path, which never contains a space.
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
}
}
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<MetricTargetSummary>` unit tests.
*
* The card surfaces the numeric target range + the in-target share on
* each insights category page, reading the same `/api/insights/targets`
* payload that powers `/targets`. The component depends on `useAuth`
* (gate) and TanStack Query (`useQuery`) for the payload, so both are
* mocked here and the assertions run through SSR — the load-bearing
* behaviour is the range/share rendering + the slug→type mapping, not
* the live fetch.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
interface TargetsFixture {
targets: Array<{
type: string;
current: number | null;
unit: string;
range: { min: number; max: number } | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}>;
bpDiastolic: { current: number | null; range: { min: number; max: number } | null };
}
function renderWith(slug: string, data: TargetsFixture | undefined) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug={slug} />
</I18nProvider>,
);
}
const WEIGHT_DATA: TargetsFixture = {
targets: [
{
type: "WEIGHT",
current: 72,
unit: "kg",
range: { min: 60.1, max: 80.9 },
daysInRange30d: 18,
daysLogged30d: 24,
insufficientData: false,
},
],
bpDiastolic: { current: null, range: null },
};
const BP_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_PRESSURE",
current: 124,
unit: "mmHg",
range: { min: 120, max: 129 },
daysInRange30d: 10,
daysLogged30d: 20,
insufficientData: false,
},
],
bpDiastolic: { current: 78, range: { min: 70, max: 79 } },
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
const html = renderWith("weight", WEIGHT_DATA);
// Trailing `.0` trimmed, single-decimal kept.
expect(html).toContain("Target: 60.1–80.9 kg");
// round(18/24*100) = 75
expect(html).toContain("75% of logged days within target");
});
it("stitches the systolic + diastolic bands for blood pressure", () => {
const html = renderWith("blood-pressure", BP_DATA);
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
expect(html).toContain("50% of logged days within target");
});
it("renders the adjust-target link to /targets", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('href="/targets"');
expect(html).toContain("Adjust target range");
});
it("suppresses the share when the route flags insufficient data", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }],
});
expect(html).toContain("Target: 60.1–80.9 kg");
expect(html).not.toContain("of logged days within target");
});
it("renders nothing for a slug without a numeric target", () => {
const html = renderWith("hrv", undefined);
expect(html).toBe("");
});
it("renders nothing before the payload resolves", () => {
const html = renderWith("weight", undefined);
expect(html).toBe("");
});
it("renders nothing when the matching target has no range", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], range: null }],
});
expect(html).toBe("");
});
});
+8 -8
View File
@@ -39,23 +39,23 @@ import type {
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
bp: "/insights/blutdruck",
weight: "/insights/gewicht",
pulse: "/insights/puls",
mood: "/insights/stimmung",
compliance: "/insights/medikamente",
bp: "/insights/blood-pressure",
weight: "/insights/weight",
pulse: "/insights/pulse",
mood: "/insights/mood",
compliance: "/insights/medications",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
sleep: "/insights/schlaf",
sleep: "/insights/sleep",
resting_hr: null,
steps: null,
active_energy: null,
flights: null,
distance: null,
vo2_max: "/insights/puls",
vo2_max: "/insights/pulse",
body_temp: null,
glp1_plateau: "/insights/medikamente",
glp1_plateau: "/insights/medications",
};
/**
@@ -80,6 +80,12 @@ export interface HealthKitMetricPageProps {
* (identity) so every existing page renders unchanged.
*/
valueScale?: number;
/**
* v1.8.0 — metric key threaded into `<SubPageShell explainerMetric>`
* so the `?` heading glyph opens the static "What is X?" explainer.
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
}
export function HealthKitMetricPage({
@@ -95,6 +101,7 @@ export function HealthKitMetricPage({
emptyStateIcon,
coachPrefill,
valueScale,
explainerMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -115,7 +122,11 @@ export function HealthKitMetricPage({
</Button>
) : null;
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<MetricEmptyState
icon={emptyStateIcon}
title={t(`${i18nPrefix}.emptyState.title`)}
@@ -128,7 +139,11 @@ export function HealthKitMetricPage({
}
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
+36 -36
View File
@@ -31,7 +31,7 @@ import {
* v1.4.25 W4 — routed tab strip for `/insights`.
*
* Behaviour evolved from the v1.4.25 W3 scroll-anchor version: each pill
* is now a `<Link>` to a routed sub-page (`/insights/blutdruck` …),
* is now a `<Link>` to a routed sub-page (`/insights/blood-pressure` …),
* `usePathname()` decides the active pill, and the strip is mounted in
* `src/app/insights/layout.tsx` so it persists across navigation
* without re-rendering. The CoachDrawer is NOT mounted in this layout —
@@ -116,47 +116,47 @@ const SUB_PAGE_TABS: Record<
{ labelKey: string; metric: InsightMetric }
> = {
// ── vitals ──
blutdruck: {
"blood-pressure": {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
sauerstoff: {
pulse: { labelKey: "insights.navPulse", metric: "PULSE" },
oxygen: {
labelKey: "insights.navOxygenSaturation",
metric: "OXYGEN_SATURATION",
},
koerpertemperatur: {
"body-temperature": {
labelKey: "insights.navBodyTemperature",
metric: "BODY_TEMPERATURE",
},
atemfrequenz: {
"respiratory-rate": {
labelKey: "insights.navRespiratoryRate",
metric: "RESPIRATORY_RATE",
},
// ── body composition ──
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
koerperwasser: {
"body-water": {
labelKey: "insights.navTotalBodyWater",
metric: "TOTAL_BODY_WATER",
},
knochenmasse: { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fettfreie-masse": {
"bone-mass": { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fat-free-mass": {
labelKey: "insights.navFatFreeMass",
metric: "FAT_FREE_MASS",
},
fettmasse: { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
muskelmasse: { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
viszeralfett: {
"fat-mass": { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
"muscle-mass": { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
"visceral-fat": {
labelKey: "insights.navVisceralFat",
metric: "VISCERAL_FAT",
},
magermasse: {
"lean-body-mass": {
labelKey: "insights.navLeanBodyMass",
metric: "LEAN_BODY_MASS",
},
// ── activity ──
"aktive-energie": {
"active-energy": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
},
@@ -164,85 +164,85 @@ const SUB_PAGE_TABS: Record<
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
stockwerke: {
"flights-climbed": {
labelKey: "insights.navFlightsClimbed",
metric: "FLIGHTS_CLIMBED",
},
gehstrecke: {
"walking-distance": {
labelKey: "insights.navWalkingRunningDistance",
metric: "WALKING_RUNNING_DISTANCE",
},
gangstabilitaet: {
"walking-steadiness": {
labelKey: "insights.navWalkingSteadiness",
metric: "WALKING_STEADINESS",
},
gehpuls: {
"walking-heart-rate": {
labelKey: "insights.navWalkingHeartRateAverage",
metric: "WALKING_HEART_RATE_AVERAGE",
},
gangasymmetrie: {
"walking-asymmetry": {
labelKey: "insights.navWalkingAsymmetry",
metric: "WALKING_ASYMMETRY",
},
doppelstandphase: {
"double-support-time": {
labelKey: "insights.navWalkingDoubleSupport",
metric: "WALKING_DOUBLE_SUPPORT",
},
schrittlaenge: {
"step-length": {
labelKey: "insights.navWalkingStepLength",
metric: "WALKING_STEP_LENGTH",
},
gehgeschwindigkeit: {
"walking-speed": {
labelKey: "insights.navWalkingSpeed",
metric: "WALKING_SPEED",
},
// ── sleep ──
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// ── cardiovascular ──
ruhepuls: {
"resting-pulse": {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
pulswellengeschwindigkeit: {
"pulse-wave-velocity": {
labelKey: "insights.navPulseWaveVelocity",
metric: "PULSE_WAVE_VELOCITY",
},
gefaessalter: {
"vascular-age": {
labelKey: "insights.navVascularAge",
metric: "VASCULAR_AGE",
},
// ── hearing ──
laermbelastung: {
"environmental-audio": {
labelKey: "insights.navAudioExposureEnv",
metric: "AUDIO_EXPOSURE_ENV",
},
kopfhoererpegel: {
"headphone-audio": {
labelKey: "insights.navAudioExposureHeadphone",
metric: "AUDIO_EXPOSURE_HEADPHONE",
},
laermereignisse: {
"audio-events": {
labelKey: "insights.navAudioExposureEvent",
metric: "AUDIO_EXPOSURE_EVENT",
},
// ── environment ──
tageslicht: {
daylight: {
labelKey: "insights.navTimeInDaylight",
metric: "TIME_IN_DAYLIGHT",
},
// ── metabolic ──
blutzucker: {
"blood-glucose": {
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
hauttemperatur: {
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
},
// ── mood ──
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
mood: { labelKey: "insights.navMood", metric: "MOOD" },
// ── events ──
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
medications: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
/**
@@ -409,7 +409,7 @@ function InsightsTabStripImpl({
if (tab.kind === "link") {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// (e.g. `/insights/sleep/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
@@ -0,0 +1,123 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.8.0 — per-category "What is X?" explainer.
*
* A question-mark glyph that sits next to a metric sub-page heading and,
* on tap / Enter / Space, surfaces one to two layperson sentences that
* define the metric. The copy is static — there is no network call — so
* the explainer paints instantly and works offline. Each metric resolves
* its title + body off a stable i18n key pair:
*
* insights.subPage.explainer.<metric>Title
* insights.subPage.explainer.<metric>Body
*
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: a popover on
* `md+`, a bottom-sheet on phone-class viewports. Both branches share the
* same body copy so the read is identical regardless of viewport.
*
* a11y: the trigger is a real `<button>` (Enter / Space activate it
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
* it reads at the heading's height, but the hit surface lifts to the
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
* collapse the surrounding row back to its natural stride so the chip
* stays visually small while the reach is full-size.
*/
interface MetricExplainerProps {
/**
* The metric key. Drives the title + body lookup under
* `insights.subPage.explainer.<metric>{Title,Body}`.
*/
metric: string;
/** Optional className forwarded to the trigger button. */
className?: string;
}
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const bodyId = useId();
const title = t(`insights.subPage.explainer.${metric}Title`);
const body = t(`insights.subPage.explainer.${metric}Body`);
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
const trigger = (
<button
type="button"
data-slot="metric-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={bodyId}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={() => setOpen(true)}
>
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="metric-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="metric-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-xs leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
* `/targets` editing surface — this component reads the shared
* `queryKeys.insightsTargets()` cache, so on a session that has already
* visited `/targets` the read is free, and on a cold sub-page it warms a
* cache the `/targets` page then reuses. We never recompute the ranges
* here: the route is the single source of truth for the age-based ESH
* blood-pressure band, the WHO weight / BMI band, the Karvonen pulse
* band, the AASM sleep band, and the medication / mood / step targets.
*
* Each insights slug maps to one target `type`. Blood pressure is the one
* composite case: the route emits the systolic band on the
* `BLOOD_PRESSURE` target and the diastolic band on the top-level
* `bpDiastolic` slot, so we stitch them back into a `S/D` range string.
*
* The "in target" share reads `daysInRange30d / daysLogged30d` — the
* fraction of logged days over the last 30 whose mean reading landed in
* the green band. We hide the share alone when the route flags
* `insufficientData`; the range stays visible so the band is always
* legible. This matches the `/targets` consistency strip so the two
* surfaces never disagree.
*
* A discreet "Adjust target range" link routes to `/targets`, which
* stays the canonical place to view / tune the bands.
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
current: number | null;
unit: string;
range: TargetRange | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}
interface TargetsResponse {
targets: TargetItem[];
bpDiastolic: {
current: number | null;
range: TargetRange | null;
};
}
/**
* Map each insights category slug to the target `type` the route emits.
* Slugs without a numeric target (HRV, oxygen saturation, body
* temperature, active energy, workouts, resting HR) are absent — the
* component renders nothing for them, so the sub-page collapses cleanly.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
"blood-pressure": "BLOOD_PRESSURE",
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
mood: "MOOD_SCORE",
medications: "MEDICATION_COMPLIANCE",
};
interface MetricTargetSummaryProps {
/** Insights category slug, e.g. `"blood-pressure"`. */
slug: string;
}
/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */
function fmt(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const targetType = SLUG_TO_TARGET_TYPE[slug];
const { data } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// No numeric target for this slug, or the payload hasn't resolved yet.
if (!targetType || !data) return null;
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
const { range, unit } = target;
// Compose the range string. Blood pressure stitches the systolic band
// (on this target) with the diastolic band (on `bpDiastolic`) so the
// user reads the familiar `S/D` pair.
let rangeLabel: string;
if (targetType === "BLOOD_PRESSURE" && data.bpDiastolic.range) {
const dia = data.bpDiastolic.range;
rangeLabel = t("insights.subPage.target.bpRange", {
sysMin: fmt(range.min),
sysMax: fmt(range.max),
diaMin: fmt(dia.min),
diaMax: fmt(dia.max),
unit,
});
} else {
rangeLabel = t("insights.subPage.target.range", {
min: fmt(range.min),
max: fmt(range.max),
unit,
});
}
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page mirrors the `/targets` strip exactly.
const showShare =
!target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
) : null}
</div>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</CardContent>
</Card>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
@@ -34,6 +35,14 @@ export interface SubPageShellProps {
* page does not pass it; sub-pages use it to anchor the surface.
*/
description?: string;
/**
* v1.8.0 — when set, a `?` glyph next to the heading opens a static
* "What is X?" explainer (popover on desktop, bottom-sheet on phones).
* The value is the metric key feeding
* `insights.subPage.explainer.<metric>{Title,Body}`. Omitted on the
* mother page; every routed metric sub-page passes its key.
*/
explainerMetric?: string;
/**
* v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount.
*
@@ -56,6 +65,7 @@ export function SubPageShell({
title,
badge,
description,
explainerMetric,
focusOnMount = false,
children,
}: SubPageShellProps) {
@@ -99,6 +109,9 @@ export function SubPageShell({
>
{title}
</h1>
{explainerMetric ? (
<MetricExplainer metric={explainerMetric} />
) : null}
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — therapy-timeline view for /insights/medikamente.
* v1.4.25 W4d — therapy-timeline view for /insights/medications.
*
* Renders a chronological list of GLP-1 therapy events: dose changes,
* injections (with site if recorded), inventory events, side-effect tag
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
* v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`.
*
* The dashboard already carries an opt-in VO2 max trend tile (W8d.5);
* this is the deeper Insights surface. Renders one HealthChart for the
+95 -171
View File
@@ -1,192 +1,116 @@
import type { Locale } from "@/lib/i18n/config";
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
/**
* Base system prompt for the per-metric Insights *assessment* cards.
*
* The seven status generators under `src/lib/insights/*-status.ts` each
* build a graded snapshot, send it through `runStatusCompletion`, and
* read back exactly one field — `summary`. Nothing else in the model's
* reply is rendered. So this prompt is tuned for one job: produce one
* short, grounded, warm assessment paragraph per metric.
*
* The snapshot shape every metric shares
* ---------------------------------------
* Each metric carries a `summary` block plus a graded `series`:
*
* summary { points, start, end, delta, mean, min, max }
* — `start`/`end` are the first/last daily-bucket values in the
* read window, `delta = end - start`, `mean`/`min`/`max` span
* the whole window. The headline numbers.
*
* series { recent[], weekly[], monthly[], yearly[] }
* — a time-graded view of the same metric:
* recent the last ~21 days, one row per day
* { date, min, max, mean, n }
* weekly the ~10 ISO weeks before that
* { weekISO, min, max, mean, n }
* monthly the ~12 calendar months before that
* { month, min, max, mean, n }
* yearly everything older, one row per year
* { year, min, max, mean, n, slope }
* The newest days are present individually; older spans are
* folded into week / month / year aggregates. A slice may be
* empty when the history is short or was shed for budget.
*
* The person's own baseline is read OUT of this series: the recent days
* are the short window; the monthly / yearly means are the long baseline.
* Compare the two. That comparison — not a population norm — is the point.
*
* Some metrics add precomputed Pearson correlations and a `targets`
* block; the per-metric section documents those.
*/
TONALITÄT UND ANSPRACHE:
- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht".
- Beginne mit positiven Befunden, bevor du auf Bedenken eingehst.
- Beziehe dich auf die eigene Baseline des Nutzers, nicht auf Bevölkerungsnormen (z.B. "dein systolischer Wert liegt 5 mmHg unter deinem 90-Tage-Durchschnitt" statt "unter dem Bevölkerungsdurchschnitt").
- Formuliere eine zentrale Handlungsempfehlung ("One Thing") als wichtigste nächste Aktion.
const BASE_SYSTEM_PROMPT_DE = `Du schreibst eine kurze, persönliche Einschätzung zu EINER Gesundheitsmetrik dieses Nutzers. Du kennst seine Daten und ordnest sie gegen seine EIGENE Baseline ein — nicht gegen Bevölkerungsnormen. Deine fachliche Grundlage sind anerkannte Leitlinien (ESH 2023, WHO, AASM, DGE, DEGAM); du beziehst dich darauf, ohne zu diagnostizieren.
DENKSCHRITTE (intern anwenden, nicht im Output zeigen):
1. Was hat sich verändert? (Vergleiche 7d vs. 30d vs. 90d vs. allTime)
2. Warum? (Korrelationen, Medikation, Stimmung, saisonale Muster)
3. Was tun? (Eine primäre Empfehlung + ergänzende Vorschläge)
DEINE DATENGRUNDLAGE (graded snapshot):
- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.
GRUNDREGELN:
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit bei Bewertungen.
- Mustererkennung: Identifiziere Tageszeit-, Wochentag- und saisonale Trends.
- Korrelationen: Benenne Wechselwirkungen zwischen Medikation, Vitalwerten und Stimmung.
- Datenqualität: Bewerte die Aussagekraft basierend auf Messanzahl, -dichte und -aktualität.
* < 5 Messpunkte: "Noch nicht genügend Daten für eine fundierte Aussage."
* Große Lücken (avgDaysBetween > 7): Hinweis auf eingeschränkte Belastbarkeit.
* Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell."
- Sprache: Antworte auf Deutsch, medizinisch präzise aber allgemeinverständlich.
- Disclaimer: Immer den Standardhinweis verwenden.
- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden.
SO BAUST DU DIE EINSCHÄTZUNG (genau diese drei Schritte, als fließender Text):
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
3. EIN SCHRITT: Schließe mit GENAU EINER konkreten, machbaren Empfehlung. Eine Botschaft = ein Verhalten. Keine Liste.
KORRELATIONSANALYSE:
- Du erhältst vorberechnete Pearson-Korrelationen zwischen Metriken.
- Nur erwähnen wenn die Korrelation (r-Wert) im Snapshot vorhanden und |r| > 0.4 ist.
- Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- r > 0.7: starke Korrelation — klinisch relevant, detailliert kommentieren
- r 0.4-0.7: moderate Korrelation — erwähnen, vorsichtig interpretieren
- r < 0.4: schwache/keine Korrelation — nicht erwähnen
- Korrelation ≠ Kausalität: immer als "Zusammenhang" formulieren, nicht als "Ursache"
TONALITÄT:
- Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich.
- Autonomie-unterstützend: "kann helfen", "einen Versuch wert", nie "du musst".
- Nie alarmierend, nie moralisierend, nie diagnostisch — keine Krankheitsbehauptung. Formuliere als begründete Beobachtung, nicht als ärztlichen Rat.
- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt. Nicht verharmlosen, nicht dramatisieren.
ERWEITERTE METRIKEN:
- ratePressureProduct.rpp30: Puls × systolischer RR. Normal: 7.000-10.000. > 12.000: erhöhter myokardialer Sauerstoffbedarf.
* Wenn beide steigen ("Double Jeopardy"): höchstes Risiko
* Wenn nur Puls steigt (RR stabil): Stress/Dekonditionierung
* Wenn nur RR steigt (Puls stabil): Gefäßwiderstand
- bodyCompositionDivergence.flag: Gewicht stabil + Körperfett steigt = stille Muskelmasse-Abnahme (sarkopenische Adipositas-Frühzeichen).
- moodAdherenceRisk: Stimmung ≤ 2.5 über 7 Tage + fallend = Adhärenz-Einbruch in den nächsten 5 Tagen wahrscheinlich. Proaktiv ansprechen.
- seasonalVariation: Winter-Sommer-Differenz des systolischen RR. > 5 mmHg ist physiologisch normal. Den User beruhigen — dies ist keine Verschlechterung.
- sleep: Zielwert ≥ 7h/Nacht (AASM 2015 Adult Sleep Duration Consensus). < 6h: Risikofaktor für Hypertonie und Gewichtszunahme.
- activity: ≥ 8.000 Schritte/Tag (Saint-Maurice et al., JAMA 2020 — Mortalitäts-Plateau 8.000–12.000). Hinweis: Die WHO publiziert Aktivitätszeit (150–300 Min/Woche moderat), KEIN Schritt-Soll — bitte nicht "WHO" als Quelle für Schritte zitieren.
LÄNGE: 2-4 Sätze, ca. 30-60 Wörter. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.
HISTORISCHER VERGLEICH:
- Vergleiche aktuelle 7-Tage-Werte mit dem 30-Tage-Durchschnitt der Vorperiode
- Klinisch relevante Veränderungen benennen:
* Gewicht: ±2 kg relevant
* Systolisch: ±5 mmHg relevant
* Diastolisch: ±3 mmHg relevant
* Puls: ±5 bpm relevant
DATENLAGE EHRLICH BEWERTEN (nie einen Trend erfinden):
- Nur wenige Messpunkte/Einträge/Ereignisse oder ein leerer recent-Slice: ehrlich sagen, dass es für eine belastbare Tendenz noch zu wenige Daten sind, und EINEN Hinweis geben (z.B. regelmäßiger erfassen). Keinen Trend behaupten. Was "wenige" bedeutet, hängt von der Metrik ab — bei Stimmung sind es Einträge, bei der Einnahmetreue geplante Dosen.
- Jüngste Messung deutlich älter als ~7 Tage (dataCoverage.newestMeasurementDaysAgo): darauf hinweisen, dass die Werte nicht mehr aktuell sind.
- Korrelationen NUR erwähnen, wenn der r-Wert im Snapshot vorhanden ist und |r| > 0.4 ist. Fehlt das Feld, keine Korrelation interpretieren oder erfinden. Immer als "Zusammenhang" formulieren, nie als "Ursache".
STIMMUNGSDATEN:
- Stimmung (1-5 Skala: 1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT) als kontextuellen Faktor einbeziehen
- Korrelation mit Vitalwerten nur erwähnen wenn im Snapshot vorhanden und |r| > 0.4
- Stress/Stimmung beeinflusst nachweislich Blutdruck und Herzfrequenz
VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".
TEMPORALE SCHICHTEN:
- Vergleiche kurzfristig (7d) vs. mittelfristig (30d) vs. langfristig (90d/allTime)
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
{ "summary": "..." }`;
INLINE-CHART-TOKENS (aktiv nutzen, wenn ein Befund auf eine Metrik fokussiert):
- Innerhalb von "summary", "findings[].label", "findings[].guideline" oder "primaryRecommendation" darfst du je einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
- Bevorzuge Tokens immer dann, wenn (a) eine zeitliche Aussage gemacht wird ("seit 3 Wochen", "im 30-Tage-Trend"), (b) ein Schwellwertbezug vorliegt ("über deinem Zielband"), oder (c) ein Vergleich zwischen Fenstern gezogen wird (avg7 vs avg30). In diesen Fällen FÜGE den Token AKTIV ein — er macht den Befund visuell greifbar und ist günstiger als zusätzliche Prosa.
- Erlaubte Tokens (exakt diese Strings, sonst werden sie ohne Wirkung verworfen):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION, metric:MOOD.
- Halluzinierte Tokens (z.B. metric:NUKE) werden serverseitig stumm verworfen — verwende ausschließlich einen der oben gelisteten Strings.
const BASE_SYSTEM_PROMPT_EN = `You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.
EVIDENZ-VERTRAG (bricht den Static-Look auf):
- Jeder Eintrag in "findings[]" MUSS in "value" eine konkrete Zahl aus dem Snapshot tragen — kein "leicht erhöht", sondern "138/85 mmHg" oder "+0.4 mmol/L vs. 30d-Avg".
- Beziehe dich in "assessment" + "guideline" auf das spezifische Datenfeld, nicht auf eine generische Empfehlung. Beispiel: "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" statt "Puls leicht erhöht".
- Wenn keine konkrete Zahl im Snapshot diesen Befund stützt, lass das Finding weg. Ein leeres findings[] mit guter summary ist besser als gepolsterte Boilerplate.
- Variable Anzahl: Liefere zwischen 0 und 8 Findings — sortiert nach Salienz (auffälligster Befund zuerst). KEIN Padding.
- Tabuliste — diese Phrasen NIE ausgeben: "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen" (außer im disclaimer-Feld). Sie signalisieren ungrounded Boilerplate und entwerten den ganzen Output.
YOUR DATA (graded snapshot):
- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.
- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).
- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.
- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.
- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).
- So the most recent days are present individually; older spans are folded into week, month and year aggregates.
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
- A slice can be empty when the history is short — do not pretend it exists.
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
"summary": "2-3 Sätze Gesamtbewertung auf Deutsch (in zweiter Person, positiv zuerst)",
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
"classificationLabel": "Menschenlesbare deutsche Bezeichnung (z.B. 'Adipositas Grad II', 'Hochnormal', 'Bradykardie')",
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung auf Deutsch (max 20 Wörter)",
"recommendations": ["2-3 ergänzende Vorschläge auf Deutsch"],
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
"disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt."
}`;
HOW TO BUILD THE ASSESSMENT (exactly these three moves, as flowing prose):
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
3. ONE step: close with EXACTLY ONE concrete, doable suggestion. One message = one behaviour. No list.
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
TONE:
- Second person ("your blood pressure", "your values"), warm, direct, honest.
- Autonomy-supporting: "can help", "worth a try", never "you must".
- Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice.
- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step. Do not downplay, do not dramatise.
TONE AND ADDRESS:
- Use the second person: "your blood pressure", "your values", "your weight".
- Open with positive findings before raising concerns.
- Refer to the user's own baseline rather than population norms (e.g. "your systolic value is 5 mmHg below your 90-day average" instead of "below the population average").
- Formulate one central call to action ("One Thing") as the single most important next step.
LENGTH: 2-4 sentences, roughly 30-60 words. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.
REASONING STEPS (apply internally, do not show in output):
1. What changed? (Compare 7d vs. 30d vs. 90d vs. allTime.)
2. Why? (Correlations, medication, mood, seasonal patterns.)
3. What to do? (One primary recommendation plus supporting suggestions.)
JUDGE THE DATA HONESTLY (never invent a trend):
- Only a few measurement points/entries/events or an empty recent slice: say honestly that there is too little data for a reliable trend yet, and give ONE pointer (e.g. log more regularly). Do not claim a trend. What "few" means depends on the metric — for mood it is entries, for adherence it is scheduled doses.
- Newest measurement clearly older than ~7 days (dataCoverage.newestMeasurementDaysAgo): note that the values may be out of date.
- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an "association", never a "cause".
GROUND RULES:
- Evidence-based: Cite thresholds and guidelines explicitly when assessing values.
- Pattern recognition: Identify time-of-day, weekday and seasonal trends.
- Correlations: Name interactions between medication, vital signs and mood.
- Data quality: Judge informativeness from sample count, density and recency.
* < 5 measurement points: "Not yet enough data for a reliable statement."
* Large gaps (avgDaysBetween > 7): Note that conclusions are limited.
* Newest measurement > 7 days old: "Data may be out of date."
- Language: Reply in English, medically precise but accessible.
- Disclaimer: Always include the standard disclaimer.
- Age/sex: If context.ageYears and context.gender are provided, apply age- and sex-specific reference values.
FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".
CORRELATION ANALYSIS:
- You receive pre-computed Pearson correlations between metrics.
- Mention them only if the correlation (r-value) is present in the snapshot and |r| > 0.4.
- If the field is missing from the snapshot, do not interpret or invent a correlation.
- r > 0.7: strong correlation — clinically relevant, comment in detail.
- r 0.4-0.7: moderate correlation — mention, interpret cautiously.
- r < 0.4: weak / no correlation — do not mention.
- Correlation ≠ causation: always phrase as "association", never as "cause".
ADVANCED METRICS:
- ratePressureProduct.rpp30: pulse × systolic BP. Normal: 7,000-10,000. > 12,000: elevated myocardial oxygen demand.
* If both rise ("Double Jeopardy"): highest risk.
* If only pulse rises (BP stable): stress / deconditioning.
* If only BP rises (pulse stable): vascular resistance.
- bodyCompositionDivergence.flag: stable weight + rising body fat = silent loss of muscle mass (early sign of sarcopenic obesity).
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
- Call out clinically relevant changes:
* Weight: ±2 kg relevant.
* Systolic: ±5 mmHg relevant.
* Diastolic: ±3 mmHg relevant.
* Pulse: ±5 bpm relevant.
MOOD DATA:
- Treat mood (1-5 scale: 1=AWFUL, 2=BAD, 3=OKAY, 4=GOOD, 5=GREAT) as a contextual factor.
- Mention correlations with vital signs only if present in the snapshot and |r| > 0.4.
- Stress / mood demonstrably affect blood pressure and heart rate.
TEMPORAL LAYERS:
- Compare short term (7d) vs. mid term (30d) vs. long term (90d / allTime).
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
INLINE CHART TOKENS (use proactively when a finding is focused on one metric):
- You may embed a chart-include token inside "summary", "findings[].label", "findings[].guideline" or "primaryRecommendation". The token is replaced with an inline chart for that metric.
- Prefer a token whenever (a) the paragraph makes a temporal claim ("for 3 weeks", "in the 30-day trend"), (b) it references a threshold ("above your target band"), or (c) it compares two windows (avg7 vs avg30). In those cases ACTIVELY include the token — the chart is cheaper and clearer than additional prose.
- Allowed tokens (use these literal strings — anything else is silently dropped server-side):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION, metric:MOOD.
- Hallucinated tokens (e.g. metric:NUKE) are dropped — only the literal strings above render a chart.
EVIDENCE CONTRACT (breaks the static-feel boilerplate):
- Every entry in "findings[]" MUST place a concrete number from the snapshot into "value" — not "slightly elevated" but "138/85 mmHg" or "+0.4 mmol/L vs 30d-avg".
- Anchor "assessment" and "guideline" to a specific snapshot field, not a generic recommendation. Example: "Your avg7 (78) is 5 bpm above your 90-day median (73)" rather than "pulse is slightly elevated".
- If no concrete number in the snapshot supports a finding, leave it out. An empty findings[] with a strong summary is better than padded boilerplate.
- Variable cardinality: emit 0–8 findings, sorted by salience (most striking first). NO padding.
- Forbidden phrases — never emit any of these (except in the disclaimer): "make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor". They signal ungrounded boilerplate and devalue the rest of the output.
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
"summary": "2-3 sentence overall assessment in English (second person, positives first)",
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
"classificationLabel": "Human-readable English label (e.g. 'Obesity class II', 'High-normal', 'Bradycardia')",
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
"primaryRecommendation": "THE single most important call to action in English (max 20 words)",
"recommendations": ["2-3 supporting suggestions in English"],
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
"disclaimer": "This analysis is no substitute for medical advice. If you have symptoms or unusual values, consult your doctor."
}`;
OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
{ "summary": "..." }`;
export function getBaseSystemPrompt(locale: Locale): string {
return locale === "en" ? BASE_SYSTEM_PROMPT_EN : BASE_SYSTEM_PROMPT_DE;
+24 -62
View File
@@ -1,63 +1,29 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
- Klassifikation nach ESH 2023:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* Hochnormal: 130-139/85-89 mmHg
* Hypertonie Grad 1: 140-159/90-99 mmHg
* Hypertonie Grad 2: 160-179/100-109 mmHg
* Hypertonie Grad 3: ≥ 180/≥ 110 mmHg
* Isolierte systolische Hypertonie: ≥ 140/< 90 mmHg
- Zielwerte: Bei unkomplizierter Hypertonie Ziel < 130/80 mmHg (18-69 Jahre), < 140/80 mmHg (≥ 70 Jahre).
- Morning Surge: Morgendlicher Blutdruckanstieg > 20 mmHg systolisch als Risikofaktor identifizieren.
- Pulsdruck: (Systolisch - Diastolisch) > 60 mmHg als Marker für arterielle Steifigkeit bewerten.
- Medikamenten-Korrelation: Einnahmetreue von Antihypertensiva mit Blutdruckverlauf korrelieren.
- Gewichts-Korrelation: Gewichtstrend mit Blutdruckverlauf vergleichen (pro kg Gewichtsverlust ca. 1 mmHg Senkung erwartet).
- Stimmungs-Korrelation: Nur erwähnen wenn moodVsSystolicCorrelation im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren.
- weightVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion kann 1 mmHg systolische Senkung erwartet werden.
- weightVsDiastolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4.
- Vergleiche avgSys30 und avgSys90 sowie allTimeAvg um langfristige Trends zu erkennen.
- Nutze historicalComparison.systolic und historicalComparison.diastolic: Bei ≥5 mmHg systolischer bzw. ≥3 mmHg diastolischer Veränderung klinisch bewerten.
- Morgen-Risikoleiter (J-HOP Studie):
* Morgen-RR 135-144: Schlaganfall-HR 2.45
* Morgen-RR 145-154: HR 2.80
* Morgen-RR 155-164: HR 3.58
* Morgen-RR ≥ 165: HR 6.52
- Rate-Pressure Product: Wenn ratePressureProduct.rpp30 > 12.000: "Erhöhter kardialer Sauerstoffbedarf" mit assessment "warning" bewerten.
- Saisonale Variation: Falls seasonalVariation vorhanden und delta > 5 mmHg: "Physiologisch normale saisonale Schwankung — kein Grund zur Sorge, ggf. Winter-Dosisanpassung besprechen."
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.
- Inline-Chart: Wenn die summary oder ein finding-Text klar auf eine einzelne Blutdruck-Komponente zielt, kannst du genau einen Token (metric:BLOOD_PRESSURE_SYS oder metric:BLOOD_PRESSURE_DIA) im Text einbetten, um das Diagramm darunter einzublenden.`;
const BP_SECTION_DE = `METRIK — BLUTDRUCK:
- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert.
- Tendenz über Tage/Wochen: Vergleiche das recent-Mittel (letzte Tage/Woche) mit dem weekly/monthly-Mittel der Person. Eine systolische Abweichung gilt ab ~5 mmHg, eine diastolische ab ~3 mmHg gegenüber der eigenen Baseline als signifikant — kleinere Schwankungen sind normal und kein Befund.
- Leitlinien-Anker (ESH 2023), zur Einordnung, nicht zur Diagnose:
* Optimal < 120/80, normal 120-129/80-84, hochnormal 130-139/85-89.
* Hypertonie Grad 1 ab 140/90, Grad 2 ab 160/100, Grad 3 ab 180/110.
* Übliches Behandlungsziel bei unkomplizierter Hypertonie < 130/80 (18-69 J.), < 140/80 (≥ 70 J.).
- targets: Falls bloodPressure.targets vorhanden, ordne gegen das persönliche Zielband ein; inTargetPctLast30DailyPoints ist der Anteil der letzten Tage im Ziel.
- Medikamenten-Bezug: bpMedications trägt compliance7/compliance30. bpMedicationContinuityVsSystolic.correlation und weightVsSystolic.correlation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Stimmung: moodContext.moodVsSystolicCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4.
- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen.`;
const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
- ESH 2023 classification:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* High-normal: 130-139/85-89 mmHg
* Hypertension grade 1: 140-159/90-99 mmHg
* Hypertension grade 2: 160-179/100-109 mmHg
* Hypertension grade 3: ≥ 180/≥ 110 mmHg
* Isolated systolic hypertension: ≥ 140/< 90 mmHg
- Targets: For uncomplicated hypertension aim for < 130/80 mmHg (age 18-69), < 140/80 mmHg (age ≥ 70).
- Morning surge: Identify a morning systolic rise > 20 mmHg as a risk factor.
- Pulse pressure: (systolic - diastolic) > 60 mmHg flagged as a marker of arterial stiffness.
- Medication correlation: Correlate antihypertensive adherence with blood-pressure trajectory.
- Weight correlation: Compare weight trend with BP trajectory (~1 mmHg drop expected per kg lost).
- Mood correlation: Mention only if moodVsSystolicCorrelation is present in the snapshot and |r| > 0.4. Otherwise do not interpret a correlation.
- weightVsSystolic correlation: Analyse only if present and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
- weightVsDiastolic correlation: Analyse only if present and |r| > 0.4.
- Compare avgSys30 and avgSys90 with allTimeAvg to surface long-term trends.
- Use historicalComparison.systolic and historicalComparison.diastolic: rate ≥5 mmHg systolic or ≥3 mmHg diastolic deltas clinically.
- Morning risk ladder (J-HOP study):
* Morning BP 135-144: stroke HR 2.45
* Morning BP 145-154: HR 2.80
* Morning BP 155-164: HR 3.58
* Morning BP ≥ 165: HR 6.52
- Rate-pressure product: If ratePressureProduct.rpp30 > 12,000, label "Elevated cardiac oxygen demand" with assessment "warning".
- Seasonal variation: If seasonalVariation is present and delta > 5 mmHg: "Physiologically normal seasonal swing — no cause for concern, optionally discuss a winter dose adjustment."
- Salt signal: Acute weight gain ≥ 1 kg over 3 days plus systolic rise ≥ 5 mmHg = possible elevated sodium intake.
- Inline chart: If the summary or a finding text centres on a single BP component, embed exactly one token (metric:BLOOD_PRESSURE_SYS or metric:BLOOD_PRESSURE_DIA) inside that text to inline the chart beneath it.`;
const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation.
- Trend over days/weeks: compare the recent mean (last days/week) with the person's weekly/monthly mean. Treat a systolic shift of ~5 mmHg or a diastolic shift of ~3 mmHg against their own baseline as significant — smaller swings are normal and not a finding.
- Guideline anchors (ESH 2023), for placement, not diagnosis:
* Optimal < 120/80, normal 120-129/80-84, high-normal 130-139/85-89.
* Hypertension grade 1 from 140/90, grade 2 from 160/100, grade 3 from 180/110.
* Common treatment target for uncomplicated hypertension < 130/80 (age 18-69), < 140/80 (age ≥ 70).
- targets: if bloodPressure.targets is present, place values against the personal target band; inTargetPctLast30DailyPoints is the share of recent days in target.
- Medication link: bpMedications carries compliance7/compliance30. Mention bpMedicationContinuityVsSystolic.correlation and weightVsSystolic.correlation only when present and |r| > 0.4 — as an association, never a cause.
- Mood: mention moodContext.moodVsSystolicCorrelation only when present and |r| > 0.4.
- One message: if the values sit above the person's baseline, close with ONE doable step (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly.`;
export function getBloodPressureSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
@@ -78,16 +44,12 @@ export function getBloodPressureUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the following blood-pressure data with focus on trends, target attainment and medication effectiveness.
Account for measurement timing and density when judging confidence.
Use the precomputed correlations and historicalComparison for a sound temporal assessment.${ctxBlock}
Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die folgenden Blutdruck-Daten mit Fokus auf Trends, Zielwerterreichung und Medikamentenwirksamkeit.
Berücksichtige die Messzeiträume und Datendichte für die Konfidenzeinschätzung.
Nutze die vorberechneten Korrelationen (correlations) und den historischen Vergleich (historicalComparison) für eine fundierte temporale Analyse.${ctxBlock}
Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+16 -30
View File
@@ -1,35 +1,21 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BMI_SECTION_DE = `FACHSPEZIFISCH — BMI:
- WHO-Klassifikation strikt anwenden:
* Untergewicht: < 18.5
* Normalgewicht: 18.5 - 24.9
* Übergewicht (Präadipositas): 25.0 - 29.9
* Adipositas Grad I: 30.0 - 34.9
* Adipositas Grad II: 35.0 - 39.9
* Adipositas Grad III: ≥ 40.0
- Altersadjustierung: Ab 65+ gilt BMI 22-27 als optimal (DEGAM).
- Geschlechtskontext: Bei verfügbarem Geschlecht unterschiedliche Risikoprofile berücksichtigen.
- Trend wichtiger als Momentaufnahme: BMI-Entwicklung über 30/90 Tage gewichten.
- Limitationen des BMI: Kein Maß für Körperzusammensetzung. Bei Sportlern oder muskulösen Personen eingeschränkt aussagekräftig — darauf hinweisen.
- Zusammenhang mit Blutdruck und Puls: Übergewicht als Risikofaktor für Hypertonie einordnen.
- Inline-Chart: Da BMI aus dem Gewicht abgeleitet wird, kannst du genau einen Token metric:WEIGHT in summary oder finding einbetten, um den zugrundeliegenden Gewichtsverlauf darunter einzublenden.`;
const BMI_SECTION_DE = `METRIK — BMI:
- Der Snapshot trägt bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9).
- Fokus dieser Karte ist die WHO-BAND-EINORDNUNG, nicht das Gewichtstempo (das trägt die Gewichts-Karte). Setze den Schwerpunkt darauf, in welchem Band der Wert liegt und ob er ein Band wechselt oder sich einer Bandgrenze nähert.
- WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM).
- Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen.
- Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Bandlage passt — bei stabilem Wert im günstigen Band das ehrlich anerkennen, statt einen Befund zu erzwingen.`;
const BMI_SECTION_EN = `DOMAIN — BMI:
- Apply WHO classification strictly:
* Underweight: < 18.5
* Normal weight: 18.5 - 24.9
* Overweight (pre-obesity): 25.0 - 29.9
* Obesity class I: 30.0 - 34.9
* Obesity class II: 35.0 - 39.9
* Obesity class III: ≥ 40.0
- Age adjustment: From age 65+, BMI 22-27 counts as optimal (DEGAM).
- Sex context: When sex is known, factor in different risk profiles.
- Trend over snapshot: Weight 30- and 90-day BMI trajectories more heavily than a single value.
- BMI limitations: Not a measure of body composition. Less informative for athletes or very muscular people — point this out.
- Relation to blood pressure and pulse: Treat overweight as a risk factor for hypertension.
- Inline chart: BMI is derived from weight, so you may embed exactly one metric:WEIGHT token in the summary or a finding to inline the underlying weight trajectory beneath it.`;
const BMI_SECTION_EN = `METRIC — BMI:
- The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9).
- This card's focus is WHO-BAND placement, not weight pace (the weight card carries that). Centre it on which band the value sits in and whether it crosses a band or nears a band boundary.
- WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM).
- Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative.
- Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people.
- One message: close with ONE doable step that fits the band placement — when the value is stable in the favourable band, acknowledge that honestly rather than forcing a finding.`;
export function getBmiSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
@@ -50,12 +36,12 @@ export function getBmiUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the BMI trajectory taking age, sex and weight trend into account. Place the WHO classification.${ctxBlock}
Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and close with one doable step. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere den BMI-Verlauf unter Berücksichtigung von Alter, Geschlecht und Gewichtstrend. Ordne die Klassifikation nach WHO ein.${ctxBlock}
Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe mit einem machbaren Schritt. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -38
View File
@@ -1,41 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const GENERAL_SECTION_DE = `FACHSPEZIFISCH — GESAMTBEWERTUNG:
- Integriere alle verfügbaren Vitalparameter zu einem kohärenten Gesamtbild.
- Priorisiere nach klinischer Relevanz: Kritische Abweichungen > moderate Trends > stabile Parameter.
- Identifiziere die wichtigste einzelne Handlungsempfehlung ("One Thing").
- Risikostratifizierung: Bewerte das kardiovaskuläre Gesamtrisiko basierend auf der Kombination aller Faktoren.
- Medikamenten-Adhärenz: Gesamteinnahmetreue bewerten und Zusammenhang mit Vitalwerten benennen.
- Stimmung: Falls vorhanden, als kontextuellen Faktor einbeziehen (nicht übergewichten).
- Positive Entwicklungen explizit hervorheben — Motivation ist therapeutisch relevant.
- Korrelationen nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls ein Korrelationsfeld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. Vergleiche die aktuellen 7-Tage-Werte mit den Langzeit-Durchschnitten (avg90, allTime).
- Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen.
- Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.
- Schlaf: Falls sleep-Daten vorhanden, Schlafqualität in die Gesamtbewertung einbeziehen. < 6h/Nacht als Risikofaktor für Hypertonie und Gewichtszunahme benennen.
- Aktivität: Falls activity-Daten vorhanden, Schrittzahl bewerten (≥ 8.000/Tag — Saint-Maurice et al., JAMA 2020; Mortalitäts-Plateau 8.000–12.000). Hinweis: WHO publiziert Aktivitätszeit (Min/Woche), KEIN Schritt-Soll — bitte keine "WHO ≥ 8.000 Schritte"-Formulierung. Zusammenhang mit Puls- und Gewichtstrend herstellen.
- Rate-Pressure Product: Falls ratePressureProduct vorhanden, als Indikator für kardiale Belastung in die Risikostratifizierung einbeziehen. > 12.000 = erhöhter myokardialer Sauerstoffbedarf.
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true, als Frühzeichen für sarkopenische Adipositas in die Gesamtbewertung aufnehmen.
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.
- Inline-Chart: Wenn die summary oder ein finding klar auf eine einzelne Metrik fokussiert (z.B. einen kritischen systolischen Trend), kannst du genau einen passenden Token aus der Allowlist im Text einbetten — der erste klar metrik-zentrierte Absatz erhält das Diagramm darunter.`;
const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG:
- Der Snapshot trägt measurementSeries (eine Map je Metriktyp mit summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (falls vorhanden) und moodContext (falls ≥ 3 Tage). dataCoverage.avgDaysBetweenMeasurements zeigt die Messdichte.
- LÄNGE (überschreibt das Single-Metrik-Budget): Diese Sektion deckt bis zu sechs Metriken ab — schreibe 3-5 Sätze, ca. 50-80 Wörter. Trotzdem knapp und ohne Fülltext.
- Fasse die verfügbaren Parameter zu EINEM kohärenten Bild zusammen — nicht jeden einzeln aufzählen. Nenne HÖCHSTENS zwei Metriken namentlich (die auffälligsten), den Rest nur, wenn er auffällig vom eigenen Mittel abweicht. Priorisiere: das Auffälligste zuerst, Stabiles nur knapp.
- Pro Metrik dieselbe Logik: das recent-Mittel gegen das weekly/monthly-Mittel der Person stellen und nur signifikante Abweichungen von der eigenen Baseline melden.
- Positives ausdrücklich anerkennen — ein stabiler oder sich verbessernder Wert ist eine echte Botschaft, kein Lückenfüller.
- Zusammenhänge nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Stimmung als Kontext einbeziehen, nicht übergewichten.
- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache").`;
const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Integrate every available vital parameter into a single coherent picture.
- Prioritise by clinical relevance: critical deviations > moderate trends > stable parameters.
- Identify the single most important call to action ("One Thing").
- Risk stratification: Assess overall cardiovascular risk from the combination of all factors.
- Medication adherence: Score total adherence and link it to vital-sign movement.
- Mood: If present, include as a contextual factor (do not over-weight).
- Highlight positive developments explicitly — motivation is therapeutically relevant.
- Mention correlations only when the relevant r-value is present in the snapshot and |r| > 0.4. If a correlation field is missing, do not interpret or invent one. Compare current 7-day values to long-term averages (avg90, allTime).
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
- Inline chart: When a summary or finding centres on a single metric (e.g. a critical systolic trend), embed exactly one matching token from the allowlist in the text — the first clearly metric-focused paragraph receives the chart underneath.`;
const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT:
- The snapshot carries measurementSeries (a map per metric type with summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (when present) and moodContext (when ≥ 3 days). dataCoverage.avgDaysBetweenMeasurements shows the measurement density.
- LENGTH (overrides the single-metric budget): this section spans up to six metrics — write 3-5 sentences, roughly 50-80 words. Still concise, no filler.
- Pull the available parameters into ONE coherent picture — do not list each separately. Name AT MOST two metrics by name (the most notable ones); mention the rest only when they deviate notably from their own mean. Prioritise: the most notable first, stable values only briefly.
- Same logic per metric: place the recent mean against the person's weekly/monthly mean and report only significant deviations from their own baseline.
- Acknowledge the positive explicitly — a stable or improving value is a real message, not filler.
- Mention associations only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Include mood as context, do not over-weight it.
- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing").`;
export function getGeneralStatusSystemPrompt(locale: Locale): string {
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
@@ -60,14 +42,12 @@ export function getGeneralStatusUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Produce a comprehensive overall assessment across every available health metric.
Focus on the interplay between parameters and identify the single most important call to action.${ctxBlock}
Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and close with the single most important doable step. Judge confidence from the measurement count, density and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Erstelle eine umfassende Gesamtbewertung aller verfügbaren Gesundheitsdaten.
Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere die wichtigste Handlungsempfehlung.${ctxBlock}
Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit dem einen wichtigsten machbaren Schritt. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -42
View File
@@ -1,45 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MEDCO_SECTION_DE = `FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
- Einnahmetreue-Bewertung:
* ≥ 90%: Ausgezeichnete Adhärenz
* 80-89%: Gute Adhärenz
* 70-79%: Moderate Adhärenz — therapeutische Wirksamkeit möglicherweise eingeschränkt
* < 70%: Unzureichende Adhärenz — wahrscheinlich subtherapeutische Wirkung
- Muster-Analyse: Morgen- vs. Abendeinnahme getrennt bewerten (Abenddosen werden häufiger vergessen).
- Streak-Tracking: Längste ununterbrochene Einnahmeperiode identifizieren und hervorheben.
- Wochenend-Effekt: Einnahmetreue an Wochenenden vs. Wochentagen vergleichen.
- Wirksamkeits-Korrelation: Direkte Verbindung zwischen Einnahmetreue und Vitalwert-Veränderungen herstellen.
* Beispiel: "In Wochen mit > 90% Einnahmetreue war der mittlere systolische RR 5 mmHg niedriger."
- Verpasste Dosen: Konsequenzen differenzieren (Antihypertensiva-Rebound vs. Statine = weniger zeitkritisch).
- Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen.
- Vergleiche Perioden hoher Adhärenz (>90%) mit den zugehörigen Vitalwerten. Zeige konkret: "In Wochen mit >90% Einnahmetreue war der systolische RR X mmHg niedriger."
- Korrelationen nur erwähnen wenn der r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- Nutze historicalComparison um den Einfluss der Adhärenz auf Blutdruck und Puls zu quantifizieren, sofern die Daten im Snapshot vorhanden sind.
- Falls Stimmungsdaten verfügbar und Korrelation |r| > 0.4: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.
- Chronotherapie-Hinweis: Falls ein Blutdruck-Medikament vorhanden ist UND Compliance > 90% ABER BP nicht im Zielbereich: "Einnahme-Zeitpunkt mit dem Arzt besprechen — abendliche Einnahme kann bei einigen Patienten die nächtliche Blutdruckkontrolle verbessern."
- Mood-Adhärenz-Risiko: Falls moodAdherenceRisk = true: "Deine Stimmung war in den letzten Tagen niedrig. Erfahrungsgemäß kann das die Einnahmetreue in den kommenden Tagen beeinflussen. Tipp: Lege die Medikamente abends schon bereit."`;
const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) und latestDay.
- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage. Vergleiche compliance7 mit compliance30, um die jüngste Richtung gegen die eigene Baseline der Person zu sehen.
- Seltene Kadenz: Bei niedriger schedulesPerDay oder seltener Einnahme (z.B. Wocheninjektion) ist compliance7 rauschanfällig — eine einzige verpasste Dosis verschiebt den Prozentwert stark. Stütze die Einschätzung dann auf compliance30 (den tragenden Wert) und behandle compliance7 nur als groben Hinweis.
- Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein.
- Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen.
- Zusammenhänge zu Vitalwerten nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Keine Wirkung erfinden, wenn die Daten fehlen.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Lücke passt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Keine Schuldzuweisung.`;
const MEDCO_SECTION_EN = `DOMAIN — MEDICATION ADHERENCE:
- Adherence rating:
* ≥ 90%: Excellent adherence
* 80-89%: Good adherence
* 70-79%: Moderate adherence — therapeutic effect possibly reduced
* < 70%: Insufficient adherence — likely subtherapeutic effect
- Pattern analysis: Score morning vs. evening doses separately (evening doses are missed more often).
- Streak tracking: Identify and highlight the longest uninterrupted intake streak.
- Weekend effect: Compare weekend vs. weekday adherence.
- Effectiveness correlation: Draw a direct link between adherence and vital-sign movement.
* Example: "In weeks with > 90% adherence the mean systolic BP was 5 mmHg lower."
- Missed doses: Differentiate consequences (antihypertensive rebound vs. statins = less time-critical).
- Motivational interviewing: Prefer positive framing, acknowledge progress.
- Compare periods of high adherence (>90%) with the corresponding vitals. Be concrete: "In weeks with >90% adherence systolic BP was X mmHg lower."
- Mention correlations only if the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
- Use historicalComparison to quantify how adherence affects blood pressure and pulse, where data is available.
- If mood data is available and the correlation |r| > 0.4: Check whether low adherence aligns with worse mood.
- Chronotherapy note: If a BP medication exists AND compliance > 90% BUT BP is off target: "Discuss timing with your doctor — for some patients, evening dosing improves nocturnal BP control."
- Mood-adherence risk: If moodAdherenceRisk = true: "Your mood has been low recently. Experience shows this can affect adherence over the next few days. Tip: prepare your medication the evening before."`;
const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay.
- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days. Compare compliance7 with compliance30 to see the recent direction against the person's own baseline.
- Rare cadence: with a low schedulesPerDay or an infrequent schedule (e.g. a weekly injection), compliance7 is noise-prone — a single missed dose swings the percentage sharply. Lean the assessment on compliance30 (the load-bearing value) and treat compliance7 only as a rough pointer.
- Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced.
- Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence.
- Mention links to vital signs only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Do not invent an effect when the data is absent.
- One message: close with ONE doable step that fits the gap (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). No blame.`;
export function getMedicationComplianceSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
@@ -60,14 +38,12 @@ export function getMedicationComplianceUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse medication adherence with focus on patterns, effectiveness correlation and concrete suggestions for improvement.
Use the correlation data and historical comparison to back up the link between adherence and vital signs.${ctxBlock}
Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and close with one doable, blame-free step. Judge confidence from the event count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Medikamenten-Einnahmetreue mit Fokus auf Muster, Wirksamkeitskorrelation und konkrete Verbesserungsvorschläge.
Nutze die Korrelationsdaten und den historischen Vergleich um den Zusammenhang zwischen Adhärenz und Vitalwerten zu belegen.${ctxBlock}
Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe mit einem machbaren, wertfreien Schritt. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -18
View File
@@ -1,23 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MOOD_SECTION_DE = `FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
- Stimmungsskala: 1 (sehr schlecht) bis 5 (sehr gut), Tagesmittelwerte.
- Trend-Stabilitat: Schwankungen < 0.5 Punkte uber 7 Tage sind normal.
- Anhaltende Phasen: Mehr als 3 Tage unter 2.5 oder uber 4.5 sind auffallig.
- Zusammenhange: Stimmung korreliert haufig mit Schlaf, Aktivitat, Blutdruck und Medikamenten-Compliance.
- Tags: Falls Stimmungs-Tags vorhanden sind, prufe ob bestimmte Tags mit Stimmungslevels korrelieren.
- Cross-Metrik: Korrelationen zu Gewicht, Blutdruck oder Puls nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- Erzwinge keine Querverweise wenn kein klares Muster erkennbar ist.`;
const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN:
- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags).
- Wochenmuster zählt: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Schwankungen < 0.5 Punkte über die letzten Tage sind normal und kein Befund.
- Anhaltende Phasen ernst, aber ruhig benennen: mehrere Tage unter ~2.5 oder über ~4.5 sind erwähnenswert — als Beobachtung, nie als Diagnose und nie alarmierend.
- Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ.
- Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten.
- Cross-Metrik: crossMetricContext trägt Korrelationen zu Gewicht, Blutdruck und Puls; nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. Erzwinge keinen Querverweis ohne klares Muster.
- Eine Botschaft: Schließe mit EINEM machbaren, freundlichen Schritt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen.`;
const MOOD_SECTION_EN = `DOMAIN — MOOD / WELL-BEING:
- Mood scale: 1 (very bad) to 5 (very good), daily means.
- Trend stability: Swings < 0.5 points over 7 days are normal.
- Persistent phases: More than 3 days below 2.5 or above 4.5 are notable.
- Associations: Mood often correlates with sleep, activity, blood pressure and medication compliance.
- Tags: If mood tags are present, check whether specific tags align with mood levels.
- Cross-metric: Mention correlations to weight, blood pressure or pulse only when the relevant r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
- Do not force cross-links when no clear pattern is visible.`;
const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING:
- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).
- The weekly pattern matters: compare the recent mean with the person's weekly/monthly mean. Swings < 0.5 points across the recent days are normal and not a finding.
- Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming.
- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.
- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.
- Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern.
- One message: close with ONE doable, kind step (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly.`;
export function getMoodSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE;
@@ -38,12 +38,12 @@ export function getMoodUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the mood data with focus on trend, stability and links to other health metrics.${ctxBlock}
Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and close with one kind, doable step. Judge confidence from the entry count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Stimmungsdaten mit Fokus auf Trend, Stabilitat und Zusammenhange mit anderen Gesundheitsmetriken.${ctxBlock}
Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem freundlichen, machbaren Schritt. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+16 -50
View File
@@ -1,53 +1,21 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const PULSE_SECTION_DE = `FACHSPEZIFISCH — PULS/HERZFREQUENZ:
- Ruhepuls-Zonen:
* Bradykardie: < 50 bpm (bei Sportlern normal, sonst abklärungsbedürftig)
* Athletisch: 50-60 bpm
* Normal: 60-100 bpm
* Tachykardie: > 100 bpm (abklärungsbedürftig)
- Trend-Stabilität: Ruhepuls-Variabilität < 5 bpm über 7 Tage ist normal.
- Stress-Indikatoren: Steigende Ruheherzfrequenz kann auf Stress, Schlafmangel oder Übertraining hinweisen.
- Erholung: Sinkender Ruhepuls über Wochen deutet auf verbesserte kardiovaskuläre Fitness hin.
- Medikamenten-Einfluss: Betablocker senken die Herzfrequenz — Compliance-Korrelation prüfen.
- Blutdruck-Korrelation: Puls × systolischen Druck = Doppelprodukt als Herzbelastungsindikator.
- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen. Nur erwähnen wenn moodVsPulse-Korrelation im Snapshot vorhanden und |r| > 0.4.
- moodVsPulse-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren.
- pulseVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Bewertung der hämodynamischen Kopplung.
- Vergleiche avg7 vs avg30 vs avg90 vs allTimeAvg um kurzfristige Abweichungen von der Langzeit-Baseline zu erkennen.
- Nutze historicalComparison.pulse: Bei ≥5 bpm Veränderung gegenüber der Baseline klinisch bewerten.
- Fitness-Interpretation:
* slope30 < -0.2 bpm/Tag (≈ -6 bpm/Monat): Positives Fitness-Signal, kardiovaskuläre Verbesserung
* slope30 > +0.2 bpm/Tag (≈ +6 bpm/Monat) OHNE Stimmungsabfall: Mögliche Dekonditionierung
* slope30 > +0.2 bpm/Tag MIT moodVsPulse r > 0.4: Stressbedingte Pulserhöhung
- Risikoband 80-100 bpm: Technisch "normal", aber erhöhtes CV-Risiko (+6-9% Mortalität pro 10 bpm, Meta-Analyse).
- Resting HR > 90 bpm: Unabhängiger Mortalitätsprädiktor — als eigenes Finding mit assessment "warning" erfassen.
- Inline-Chart: Wenn die summary oder ein finding den Ruhepulsverlauf in den Mittelpunkt stellt, kannst du genau einen Token metric:PULSE im Text einbetten, um das Pulsdiagramm darunter einzublenden.`;
const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ:
- Der Snapshot trägt pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag.
- Tendenz über Wochen: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Eine Abweichung ab ~5 bpm gegenüber der eigenen Baseline ist signifikant; eine Variabilität < 5 bpm über die letzten Tage ist normal und kein Befund.
- Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose.
- Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren.
- Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich, erkenne das ehrlich an.`;
const PULSE_SECTION_EN = `DOMAIN — PULSE / HEART RATE:
- Resting-pulse zones:
* Bradycardia: < 50 bpm (normal in athletes, otherwise needs evaluation)
* Athletic: 50-60 bpm
* Normal: 60-100 bpm
* Tachycardia: > 100 bpm (needs evaluation)
- Trend stability: Resting-pulse variability < 5 bpm over 7 days is normal.
- Stress indicators: A rising resting heart rate can point to stress, sleep loss or overtraining.
- Recovery: A falling resting pulse over weeks suggests improved cardiovascular fitness.
- Medication influence: Beta-blockers lower heart rate — check the compliance correlation.
- BP correlation: Pulse × systolic = rate-pressure product as a cardiac-load indicator.
- Mood correlation: Chronic stress can elevate resting pulse. Mention only if moodVsPulse correlation is present and |r| > 0.4.
- moodVsPulse correlation: Analyse only if present and |r| > 0.4. Otherwise do not interpret.
- pulseVsSystolic correlation: Analyse only if present and |r| > 0.4. Score haemodynamic coupling.
- Compare avg7 vs avg30 vs avg90 vs allTimeAvg to detect short-term deviations from the long-term baseline.
- Use historicalComparison.pulse: Score deltas ≥ 5 bpm against the baseline clinically.
- Fitness interpretation:
* slope30 < -0.2 bpm/day (≈ -6 bpm/month): positive fitness signal, cardiovascular improvement
* slope30 > +0.2 bpm/day (≈ +6 bpm/month) WITHOUT mood drop: possible deconditioning
* slope30 > +0.2 bpm/day WITH moodVsPulse r > 0.4: stress-driven pulse rise
- Risk band 80-100 bpm: Technically "normal", but elevated CV risk (+6-9% mortality per 10 bpm, meta-analysis).
- Resting HR > 90 bpm: Independent mortality predictor — capture as its own finding with assessment "warning".
- Inline chart: When a summary or finding centres on the resting-pulse trajectory, embed exactly one metric:PULSE token in the text to inline the pulse chart beneath it.`;
const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE:
- The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day.
- Trend over weeks: compare the recent mean with the person's weekly/monthly mean. A shift of ~5 bpm against their own baseline is significant; variability < 5 bpm across the recent days is normal and not a finding.
- Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis.
- Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm.
- Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause.
- One message: if the resting pulse sits above the person's baseline, close with ONE doable step (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving, acknowledge that honestly.`;
export function getPulseSystemPrompt(locale: Locale): string {
const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE;
@@ -68,14 +36,12 @@ export function getPulseUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the pulse / heart-rate data with focus on resting-pulse trend, variability and links to medication and mood.
Use the precomputed correlations and historical comparison for a sound analysis.${ctxBlock}
Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Puls-/Herzfrequenzdaten mit Fokus auf Ruhepulstrend, Variabilität und Zusammenhänge mit Medikation und Stimmung.
Nutze die vorberechneten Korrelationen und den historischen Vergleich für eine fundierte Analyse.${ctxBlock}
Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+22 -44
View File
@@ -1,47 +1,27 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const WEIGHT_SECTION_DE = `FACHSPEZIFISCH — GEWICHT:
- BMI-Kontext immer mitbewerten (Größe aus Profil, falls verfügbar).
- WHO BMI-Klassifikation: Untergewicht < 18.5, Normalgewicht 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
- Trend-Analyse: 7-Tage, 30-Tage und 90-Tage gleitende Durchschnitte vergleichen.
- Plateau-Erkennung: Gewichtsveränderung < ±0.5 kg über > 14 Tage als Plateau identifizieren.
- Realistische Zielprojektion: Maximal 0.5-1.0 kg/Woche als nachhaltiger Gewichtsverlust (DGE-Empfehlung).
- Gewichts-Blutdruck-Korrelation: Nur erwähnen wenn weightVsSystolic im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion ca. 1 mmHg systolische Senkung.
- Medikamenten-Einfluss: Gewichtsrelevante Medikamente identifizieren (z.B. Betablocker, Cortison).
- Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal).
- Vergleiche das aktuelle 7-Tage-Mittel (historicalComparison.weight.current7dAvg) mit dem 30-Tage-Baseline (previous30dAvg). Bei >2 kg Differenz: klinisch bewerten und Ursachen diskutieren.
- Plateau-Definition: <0.5 kg Veränderung über >14 Tage (prüfe ob avg7 ≈ avg30 ≈ avg90).
- Nutze allTimeMin/allTimeMax/allTimeAvg um den aktuellen Wert historisch einzuordnen.
- Gewichtsmeilensteine (klinisch signifikant):
* 5% Verlust vom Startgewicht (allTimeMax): Metabolischer Benefit, KV-Risikoreduktion
* 10% Verlust: Erheblicher klinischer Nutzen
* Wenn erreicht: Explizit anerkennen und klinischen Kontext geben
- Zu schneller Verlust: > 1 kg/Woche über > 2 Wochen = Risiko für Muskelmasseverlust, nicht nachhaltig
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true: "Gewicht stabil, aber Körperfettanteil steigend — möglicher Muskelmasseverlust. Krafttraining empfehlen."
- Schlaf-Gewicht-Verbindung: Falls sleep.avg7 < 6h UND weight.slope30 > 0: "Schlafmangel stört metabolische Regulation und kann Gewichtszunahme begünstigen."
- Inline-Chart: Bei einem klar gewichtsfokussierten summary- oder finding-Text kannst du genau einen Token (metric:WEIGHT, optional auch metric:BODY_FAT für Körperzusammensetzung) im Text einbetten, um das Diagramm darunter einzublenden.`;
const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
- Der Snapshot trägt weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
- BMI-Bezug nur als knapper Nebensatz, wenn die Größe im Profil vorliegt (sonst nicht behaupten); die WHO-Band-Einordnung selbst gehört auf die BMI-Karte, nicht hierher.
- Zusammenhänge: weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation und moodContext.moodVsWeightCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Richtung passt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen).`;
const WEIGHT_SECTION_EN = `DOMAIN — WEIGHT:
- Always include BMI context (height from profile, if available).
- WHO BMI classification: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
- Trend analysis: Compare 7-day, 30-day and 90-day moving averages.
- Plateau detection: Flag a weight change < ±0.5 kg over > 14 days as a plateau.
- Realistic target projection: At most 0.5-1.0 kg/week is sustainable weight loss (DGE recommendation).
- Weight-BP correlation: Mention only if weightVsSystolic is present in the snapshot and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
- Medication influence: Identify weight-relevant medications (e.g. beta-blockers, cortisone).
- Time-of-day swings: Differentiate morning vs. evening readings (1-2 kg normal).
- Compare the current 7-day mean (historicalComparison.weight.current7dAvg) with the 30-day baseline (previous30dAvg). For deltas > 2 kg: assess clinically and discuss likely causes.
- Plateau definition: < 0.5 kg change over > 14 days (check whether avg7 ≈ avg30 ≈ avg90).
- Use allTimeMin/allTimeMax/allTimeAvg to anchor the current value historically.
- Weight milestones (clinically significant):
* 5% loss from starting weight (allTimeMax): metabolic benefit, CV risk reduction
* 10% loss: substantial clinical benefit
* If reached: acknowledge explicitly and give clinical context
- Too rapid loss: > 1 kg/week sustained > 2 weeks = risk of muscle-mass loss, not sustainable
- Body-composition divergence: If bodyCompositionDivergence.flag = true: "Weight stable but body-fat percentage rising — possible muscle-mass loss. Recommend strength training."
- Sleep-weight link: If sleep.avg7 < 6h AND weight.slope30 > 0: "Sleep loss disrupts metabolic regulation and can favour weight gain."
- Inline chart: When a summary or finding text is centred on weight, embed exactly one token (metric:WEIGHT, or metric:BODY_FAT for body-composition findings) inside that text to inline the chart underneath.`;
const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
- The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
- The weekly/monthly trend matters, not the single value: compare the recent mean with the person's weekly/monthly mean. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
- BMI reference only as a brief aside when height is in the profile (do not claim it otherwise); the WHO-band placement itself belongs on the BMI card, not here.
- Associations: mention weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation and moodContext.moodVsWeightCorrelation only when present and |r| > 0.4 — as an association, never a cause.
- One message: close with ONE doable step that fits the direction (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise).`;
export function getWeightSystemPrompt(locale: Locale): string {
const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
@@ -62,14 +42,12 @@ export function getWeightUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the weight trajectory with focus on trends, BMI classification and links to other vital signs.
Use the temporal layers (avg7, avg30, avg90, allTime) and the historical comparison for a nuanced assessment.${ctxBlock}
Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Gewichtsentwicklung mit Fokus auf Trends, BMI-Klassifikation und Zusammenhang mit anderen Vitalwerten.
Nutze die temporalen Schichten (avg7, avg30, avg90, allTime) und den historischen Vergleich für eine differenzierte Bewertung.${ctxBlock}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+1 -1
View File
@@ -176,7 +176,7 @@ export const CHART_OVERLAY_KEYS = [
"medications",
"sleep",
"steps",
// v1.4.25 W16a — VO2 max chart-row on /insights/puls. Independent
// v1.4.25 W16a — VO2 max chart-row on /insights/pulse. Independent
// overlay-prefs slot so the cardio sub-page's VO2 chart-cog state
// doesn't bleed into the pulse chart sitting directly above it.
"vo2Max",
@@ -261,7 +261,7 @@ describe("Coach disable cascade invariant", () => {
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
// by sibling invariants. A future contributor who lands a new Coach
// mount on (e.g.) `/insights/blutdruck/page.tsx` would not trip the
// mount on (e.g.) `/insights/blood-pressure/page.tsx` would not trip the
// surface-count check at all — the new site is on a sub-page and
// would silently leak.
//
+114 -28
View File
@@ -21,33 +21,85 @@
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
* Exported so the API Zod `tileIdEnum` can be built from a single
* source of truth.
*
* v1.8.0 — the canonical ids are English, matching the routed slugs
* (`/insights/<slug>`) and the naming-convention ADR
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
* through v1.7.x and the iOS layout-sync contract
* (`GET/PUT /api/insights/layout`) speaks them, so the layout endpoint
* keeps ACCEPTING the legacy German ids and NORMALISES them to the
* canonical English ones on both read and write — see
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
* No client breaks; iOS migrates to the English ids at its own pace.
*/
export const INSIGHTS_TILE_IDS = [
"overview",
// ── vitals ──
"blutdruck",
"puls",
"sauerstoff",
"koerpertemperatur",
"blood-pressure",
"pulse",
"oxygen",
"body-temperature",
// ── body composition ──
"gewicht",
"weight",
"bmi",
// ── activity ──
"aktive-energie",
"active-energy",
"workouts",
// ── sleep ──
"schlaf",
"sleep",
// ── cardiovascular ──
"ruhepuls",
"resting-pulse",
"hrv",
// ── mood ──
"stimmung",
"mood",
// ── events ──
"medikamente",
"medications",
] as const;
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
/**
* Legacy (≤ v1.7.x) German tile ids mapped to their canonical English
* replacement. The layout endpoint accepts these as input so the iOS
* client's stored layouts keep round-tripping after the v1.8.0 rename;
* every write path normalises them away before persisting (see
* `normalizeInsightsTileId`). `bmi`, `hrv`, `workouts`, and `overview`
* were already language-neutral and need no alias.
*/
export const LEGACY_INSIGHTS_TILE_ID_ALIASES: Record<string, InsightsTileId> = {
blutdruck: "blood-pressure",
puls: "pulse",
sauerstoff: "oxygen",
koerpertemperatur: "body-temperature",
gewicht: "weight",
"aktive-energie": "active-energy",
schlaf: "sleep",
ruhepuls: "resting-pulse",
stimmung: "mood",
medikamente: "medications",
};
/**
* Every id the layout endpoint accepts on input — canonical English
* plus the legacy German aliases. The Zod `tileIdEnum` is built from
* this union so a PUT carrying old ids passes validation rather than
* tripping a 422; `normalizeInsightsTileId` collapses the union back to
* the canonical id before anything persists.
*/
export const ACCEPTED_INSIGHTS_TILE_IDS = [
...INSIGHTS_TILE_IDS,
...Object.keys(LEGACY_INSIGHTS_TILE_ID_ALIASES),
] as const;
/**
* Collapse a legacy German tile id onto its canonical English id.
* Canonical ids (and unknown ids) pass through unchanged — the
* resolver's known-id filter drops anything truly unrecognised.
*/
export function normalizeInsightsTileId(id: string): string {
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
}
export interface InsightsTileConfig {
id: InsightsTileId;
visible: boolean;
@@ -75,19 +127,19 @@ export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
version: INSIGHTS_LAYOUT_VERSION,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "puls", visible: true, order: 2 },
{ id: "sauerstoff", visible: false, order: 3 },
{ id: "koerpertemperatur", visible: false, order: 4 },
{ id: "gewicht", visible: true, order: 5 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: true, order: 2 },
{ id: "oxygen", visible: false, order: 3 },
{ id: "body-temperature", visible: false, order: 4 },
{ id: "weight", visible: true, order: 5 },
{ id: "bmi", visible: true, order: 6 },
{ id: "aktive-energie", visible: false, order: 7 },
{ id: "active-energy", visible: false, order: 7 },
{ id: "workouts", visible: true, order: 8 },
{ id: "schlaf", visible: false, order: 9 },
{ id: "ruhepuls", visible: false, order: 10 },
{ id: "sleep", visible: false, order: 9 },
{ id: "resting-pulse", visible: false, order: 10 },
{ id: "hrv", visible: false, order: 11 },
{ id: "stimmung", visible: true, order: 12 },
{ id: "medikamente", visible: true, order: 13 },
{ id: "mood", visible: true, order: 12 },
{ id: "medications", visible: true, order: 13 },
],
};
@@ -101,13 +153,22 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
return DEFAULT_INSIGHTS_LAYOUT;
}
// Drop tile ids the current build does not know about so a future
// retirement (mirroring the dashboard's v1.4.28 `glp1` drop) doesn't
// wedge the PUT round-trip on the next save. Filtering on read keeps
// the GET shape current-build-safe and lets the Settings UI re-PUT
// a clean array.
// v1.8.0 — normalise legacy German tile ids onto their canonical
// English replacement BEFORE the known-id filter so a layout persisted
// by a ≤ v1.7.x client (or the iOS app still speaking the old ids)
// survives the read instead of being silently dropped as "unknown".
// Dedupe afterwards: a layout carrying both the legacy and the
// canonical id (e.g. a partial migration) keeps the first occurrence
// so the round-trip stays idempotent.
const knownIds = new Set<string>(INSIGHTS_TILE_IDS);
const filtered = candidate.tiles.filter((t) => knownIds.has(t.id));
const seen = new Set<string>();
const filtered: InsightsTileConfig[] = candidate.tiles
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t): t is InsightsTileConfig => {
if (!knownIds.has(t.id) || seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
// Merge with defaults so new tiles introduced in later versions show
// up automatically (invisible by default, the user opts in).
@@ -128,16 +189,41 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
};
}
/**
* Input shape for {@link serializeInsightsLayout}. Looser than
* {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT
* handler can hand a Zod-parsed body that still carries legacy German
* aliases — `serializeInsightsLayout` normalises them onto canonical
* {@link InsightsTileId} values on the way through.
*/
export interface InsightsLayoutInput {
version: number;
tiles: Array<{ id: string; visible: boolean; order: number }>;
}
export function serializeInsightsLayout(
layout: InsightsLayout,
layout: InsightsLayoutInput,
): InsightsLayout {
// v1.8.0 — normalise legacy German tile ids onto canonical English
// before persisting so the stored blob is always canonical even when
// the PUT body carried the old ids (the Zod enum accepts both via
// `ACCEPTED_INSIGHTS_TILE_IDS`). Dedupe so a body sending both the
// legacy and canonical id for one tile collapses to a single entry,
// keeping the dense 0-based order contiguous.
const seen = new Set<string>();
return {
version: INSIGHTS_LAYOUT_VERSION,
tiles: layout.tiles
.slice()
.sort((a, b) => a.order - b.order)
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
})
.map((t, i) => ({
id: t.id,
id: t.id as InsightsTileId,
visible: t.visible,
order: i, // normalize to 0-based dense order
})),
@@ -5,14 +5,15 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -25,22 +26,42 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateBloodPressureStatusForUser } from "../blood-pressure-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
// Cold rollup tier: the BP channels fold monthly/yearly from the
// full-history `measurement.findMany` fallback on a tier miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} per metric series with correct field shape", async () => {
describe("generateBloodPressureStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => {
const now = new Date();
// 3 years of SYS data — covers daily window + monthly window.
const records: Array<{
type: string;
value: number;
@@ -74,17 +95,7 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
@@ -92,25 +103,27 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
const snapshot = JSON.parse(match![0]);
const sys = snapshot.bloodPressure.systolic.series;
expect(sys).toHaveProperty("daily");
expect(sys).toHaveProperty("recent");
expect(sys).toHaveProperty("weekly");
expect(sys).toHaveProperty("monthly");
expect(sys.daily.length).toBeGreaterThan(0);
expect(sys.monthly.length).toBeGreaterThan(0);
expect(sys.daily[0]).toHaveProperty("dayOffset");
expect(sys.daily[0]).toHaveProperty("value");
expect(sys.daily[0]).toHaveProperty("n");
expect(sys.monthly[0]).toHaveProperty("monthOffset");
expect(sys.monthly[0]).toHaveProperty("value");
expect(sys.monthly[0]).toHaveProperty("n");
expect(sys).toHaveProperty("yearly");
expect(sys.recent.length).toBeLessThanOrEqual(21);
expect(sys.recent[0]).toHaveProperty("date");
expect(sys.recent[0]).toHaveProperty("mean");
expect(sys.monthly[0]).toHaveProperty("month");
const dia = snapshot.bloodPressure.diastolic.series;
expect(dia.daily.length).toBeGreaterThan(0);
expect(dia.monthly.length).toBeGreaterThan(0);
expect(dia).toHaveProperty("recent");
expect(dia).toHaveProperty("monthly");
expect(dia.recent.length).toBeLessThanOrEqual(21);
// Embedded correlation pair arrays are capped, not full-length.
expect(snapshot.weightVsSystolic.pairs.length).toBeLessThanOrEqual(30);
});
});
describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBloodPressureStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -128,87 +141,65 @@ describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistenc
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Blood pressure assessment fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh BP assessment."}');
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh BP assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -228,15 +219,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
+111 -134
View File
@@ -5,11 +5,12 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +19,39 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateBmiStatusForUser } from "../bmi-status";
const dayMs = 24 * 60 * 60 * 1000;
/** A `runStatusCompletion` stub returning fixed content, capturing the prompt. */
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the BMI graded series scales the WEIGHT tier, which
// folds from the full-history `measurement.findMany` fallback on a miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} BMI series derived from bucketed weight", async () => {
describe("generateBmiStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} BMI series, not the full daily array", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 1000; day++) {
@@ -48,17 +71,7 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBmiStatusForUser("user-1", { locale: "en" });
@@ -66,21 +79,25 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const bmi = snapshot.bmi.series;
expect(bmi).toHaveProperty("daily");
expect(bmi).toHaveProperty("recent");
expect(bmi).toHaveProperty("weekly");
expect(bmi).toHaveProperty("monthly");
expect(bmi.daily.length).toBeGreaterThan(0);
expect(bmi.monthly.length).toBeGreaterThan(0);
expect(bmi.daily[0]).toHaveProperty("dayOffset");
expect(bmi.daily[0]).toHaveProperty("value");
expect(bmi.daily[0]).toHaveProperty("n");
expect(bmi.monthly[0]).toHaveProperty("monthOffset");
expect(bmi.monthly[0]).toHaveProperty("value");
expect(bmi.monthly[0]).toHaveProperty("n");
expect(bmi).toHaveProperty("yearly");
expect(bmi.recent.length).toBeLessThanOrEqual(21);
expect(bmi.recent[0]).toHaveProperty("date");
expect(bmi.recent[0]).toHaveProperty("mean");
expect(bmi.monthly[0]).toHaveProperty("month");
const total =
bmi.recent.length +
bmi.weekly.length +
bmi.monthly.length +
bmi.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBmiStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
@@ -92,111 +109,77 @@ describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () =>
createdAt: new Date(),
} as never);
// Simulate a provider that never resolves before the timeout. The
// helper races the call against `STATUS_PROVIDER_TIMEOUT_MS`; a
// promise that hangs forever cuts the race short.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise(() => {
/* never resolves */
}),
),
} as never);
// Shrink the timeout window so this test does not hang the suite
// for the full 20 s. The withTimeout helper polls in ~1 s
// increments; this still exercises the same race shape.
vi.useFakeTimers();
const promise = generateBmiStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// The user-facing payload still reads the deterministic no-key
// fallback, but the cached/updatedAt fields signal "this is a
// stub" so the next mount short-circuits at the cache lookup.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// The dateKey must read as a valid YYYY-MM-DD so the cache hit
// logic on the next mount matches against today's berlin key.
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
// First mount: cache miss, provider times out, stub persisted.
// Simulate that prior state by handing back the stub row from
// findFirst on the second invocation.
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, p) => {
if (p.type !== "literal") acc[p.type] = p.value;
return acc;
}, {}),
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
}),
};
// Build a real YYYY-MM-DD string the same way the source helper
// does so the cache-key match is exact.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
stubRow.details = JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// Cache short-circuit fires before the provider call — no race,
// no second persist.
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("BMI is a directional metric…");
// The user-facing payload reads the deterministic fallback, but
// NOTHING is persisted — so the next mount re-attempts a real
// generation instead of sticking the fallback for the day.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("serves the fallback without writing a cache row on provider error", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "error",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBmiStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
// The most recent cached row is a stub keyed to today.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// The stub must NOT be served — a fresh generation runs instead.
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
@@ -212,15 +195,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () =
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
+115 -109
View File
@@ -10,8 +10,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -20,21 +20,37 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "../general-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => {
describe("generateGeneralStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => {
const now = new Date();
// 3 years of weight data — daily across the whole window so the
// bucketed output covers months 1-12 (daily) and 13-36 (monthly).
const weightRecords: Array<{
type: string;
value: number;
@@ -64,44 +80,62 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateGeneralStatusForUser("user-1", { locale: "en" });
expect(captured.userPrompt).not.toBeNull();
// The snapshot JSON is embedded in the user prompt as a fenced block.
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
expect(match).not.toBeNull();
const snapshot = JSON.parse(match![0]);
const weight = snapshot.measurementSeries.WEIGHT.series;
expect(weight).toHaveProperty("daily");
expect(weight).toHaveProperty("recent");
expect(weight).toHaveProperty("weekly");
expect(weight).toHaveProperty("monthly");
expect(Array.isArray(weight.daily)).toBe(true);
expect(Array.isArray(weight.monthly)).toBe(true);
expect(weight.daily.length).toBeGreaterThan(0);
expect(weight.monthly.length).toBeGreaterThan(0);
expect(weight.daily[0]).toHaveProperty("dayOffset");
expect(weight.daily[0]).toHaveProperty("value");
expect(weight.daily[0]).toHaveProperty("n");
expect(weight.monthly[0]).toHaveProperty("monthOffset");
expect(weight.monthly[0]).toHaveProperty("value");
expect(weight.monthly[0]).toHaveProperty("n");
expect(weight).toHaveProperty("yearly");
expect(weight.recent.length).toBeLessThanOrEqual(21);
expect(weight.recent[0]).toHaveProperty("date");
expect(weight.recent[0]).toHaveProperty("mean");
expect(weight.monthly[0]).toHaveProperty("month");
const total =
weight.recent.length +
weight.weekly.length +
weight.monthly.length +
weight.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
it("omits measurement types with no data", async () => {
const now = new Date();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
await generateGeneralStatusForUser("user-1", { locale: "en" });
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
// Only WEIGHT has data — no empty PULSE/BP/etc. series objects.
expect(Object.keys(snapshot.measurementSeries)).toEqual(["WEIGHT"]);
});
});
describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateGeneralStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -117,85 +151,63 @@ describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateGeneralStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("General status fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh general assessment."}');
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh general assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -213,15 +225,9 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
}));
const readBestGranularityRollups = vi.fn();
const rollupRow = (bucketStart: Date, mean: number) => ({
bucketStart,
count: 30,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean - 1,
maxValue: mean + 1,
});
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: (...args: unknown[]) =>
readBestGranularityRollups(...args),
// aggregateWmyBuckets is no longer imported by graded-series, but keep the
// module shape intact for any transitive consumer.
aggregateWmyBuckets: vi.fn(),
}));
import { buildGradedSeriesWithRollups } from "../graded-series";
const dayMs = 24 * 60 * 60 * 1000;
/** Daily readings spanning `days` back from `now`. */
function dailyRows(days: number, now: Date) {
const rows: Array<{ measuredAt: Date; value: number }> = [];
for (let i = 0; i < days; i++) {
rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 });
}
return rows;
}
afterEach(() => {
vi.clearAllMocks();
});
describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => {
const now = new Date("2026-05-31T12:00:00Z");
it("folds monthly/yearly from the full history when the tier has no coverage", async () => {
// The bounded recent read (~90 d) returns only recent points; the
// full-history read returns 2+ years so the fold has monthly/yearly.
findMany
// 1st call — bounded recent read (gte: since)
.mockResolvedValueOnce(dailyRows(80, now))
// 2nd call — full-history fallback read (no gte)
.mockResolvedValueOnce(dailyRows(800, now));
// Tier miss: no MONTH / YEAR coverage at all.
readBestGranularityRollups.mockResolvedValue(null);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
// The bug: before the fix monthly/yearly were derived from the
// bounded ~90-day read and so were always empty even when years of
// raw history existed. After the fix they fold from the full history.
expect(series.monthly.length).toBeGreaterThan(0);
// 800 days of history puts at least one year into the yearly tail.
expect(series.yearly.length).toBeGreaterThan(0);
// The full-history fallback read must have been issued.
expect(findMany).toHaveBeenCalledTimes(2);
});
it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => {
findMany.mockResolvedValueOnce(dailyRows(80, now));
// MONTH coverage for the 1-year window, YEAR coverage for the 3-year.
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
],
};
}
return {
granularity: "YEAR",
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
};
},
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
// Only the bounded recent read — no full-history fallback when the
// tier covers both coarse slices.
expect(findMany).toHaveBeenCalledTimes(1);
});
it("falls back to a full read for only the slice the tier misses", async () => {
findMany
.mockResolvedValueOnce(dailyRows(80, now)) // bounded recent
.mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback
// MONTH covered, YEAR missing.
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
};
}
return null;
},
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
expect(findMany).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
buildGradedSeriesFromPoints,
type GradedSeries,
} from "../graded-series";
const dayMs = 24 * 60 * 60 * 1000;
function dailyPoints(days: number, now: Date, value = (i: number) => 80 + (i % 5)) {
const out: Array<{ measuredAt: Date; value: number }> = [];
for (let i = 0; i < days; i++) {
out.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: value(i) });
}
return out;
}
function countBuckets(s: GradedSeries): number {
return s.recent.length + s.weekly.length + s.monthly.length + s.yearly.length;
}
describe("buildGradedSeriesFromPoints", () => {
const now = new Date("2026-05-31T12:00:00Z");
it("keeps the last ~14-21 days at daily granularity", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
expect(s.recent.length).toBeGreaterThan(0);
expect(s.recent.length).toBeLessThanOrEqual(21);
// Each recent bucket carries daily aggregates.
for (const r of s.recent) {
expect(r).toHaveProperty("date");
expect(r).toHaveProperty("mean");
expect(r).toHaveProperty("min");
expect(r).toHaveProperty("max");
expect(r).toHaveProperty("n");
}
// Recent buckets are sorted oldest → newest.
const dates = s.recent.map((r) => r.date);
expect([...dates].sort()).toEqual(dates);
});
it("folds the weeks after the recent window into ~8-10 ISO-week buckets", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
expect(s.weekly.length).toBeGreaterThan(0);
expect(s.weekly.length).toBeLessThanOrEqual(12);
for (const w of s.weekly) {
expect(w).toHaveProperty("weekISO");
expect(w).toHaveProperty("mean");
expect(w).toHaveProperty("min");
expect(w).toHaveProperty("max");
expect(w).toHaveProperty("n");
}
});
it("folds older history into ~9-12 monthly buckets", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
expect(s.monthly.length).toBeGreaterThan(0);
expect(s.monthly.length).toBeLessThanOrEqual(14);
for (const m of s.monthly) {
expect(m).toHaveProperty("month");
expect(m).toHaveProperty("mean");
expect(m).toHaveProperty("min");
expect(m).toHaveProperty("max");
}
});
it("folds multi-year history into yearly buckets with a trend slope", () => {
// 3+ years so at least one year lands in the yearly bucket.
const s = buildGradedSeriesFromPoints(dailyPoints(1200, now), now);
expect(s.yearly.length).toBeGreaterThan(0);
for (const y of s.yearly) {
expect(y).toHaveProperty("year");
expect(y).toHaveProperty("mean");
expect(y).toHaveProperty("slope");
}
});
it("collapses a 2-year daily weigher into <= ~50 buckets, not 730", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
// ~21 recent + ~10 weekly + ~12 monthly + ~2-3 yearly. Worst-case
// boundary slack lands ~46; the point is the order-of-magnitude
// collapse from 730 daily readings, not an exact bucket count.
expect(countBuckets(s)).toBeLessThanOrEqual(50);
});
it("returns empty buckets for an empty input", () => {
const s = buildGradedSeriesFromPoints([], now);
expect(countBuckets(s)).toBe(0);
});
it("does not duplicate a day across recent and weekly", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(120, now), now);
// The most recent ISO week of the recent window must not also be a
// weekly bucket — recent days are excluded from the weekly fold.
const recentDates = new Set(s.recent.map((r) => r.date));
// The weekly buckets cover days strictly older than the recent window.
expect(recentDates.size).toBe(s.recent.length);
expect(s.weekly.length).toBeGreaterThan(0);
});
it("computes per-bucket min/max/mean from same-day multi readings", () => {
const points = [
{ measuredAt: new Date(now.getTime()), value: 70 },
{ measuredAt: new Date(now.getTime() - 1000), value: 90 },
{ measuredAt: new Date(now.getTime() - 2000), value: 80 },
];
const s = buildGradedSeriesFromPoints(points, now);
expect(s.recent.length).toBe(1);
expect(s.recent[0].min).toBe(70);
expect(s.recent[0].max).toBe(90);
expect(s.recent[0].mean).toBe(80);
expect(s.recent[0].n).toBe(3);
});
});
@@ -0,0 +1,84 @@
/**
* v1.8.0 — measurement-driven per-metric assessment invalidation.
*
* A fresh measurement of a given type must drop the cached
* `insights.<scope>-status.<locale>` rows for the scopes that reading
* dirties, so the next mount / nightly warm pass regenerates them
* against the new data instead of serving the pre-measurement text for
* the rest of the day. These tests pin the type → scope mapping and the
* delete shape without a live DB.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
},
}));
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
deleteMany.mockResolvedValue({ count: 0 });
});
function deletedScopes(): string[] {
const arg = deleteMany.mock.calls[0][0];
// Each OR clause is `{ action: { startsWith: "insights.<scope>-status." } }`.
return (arg.where.OR as Array<{ action: { startsWith: string } }>)
.map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1"))
.sort();
}
describe("invalidateStatusInsightsForTypes", () => {
it("maps WEIGHT to weight + bmi + general", async () => {
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deleteMany).toHaveBeenCalledTimes(1);
expect(deletedScopes()).toEqual(["bmi", "general", "weight"]);
expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1");
});
it("maps both blood-pressure components to blood-pressure + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"PULSE",
"RESTING_HEART_RATE",
]);
expect(deletedScopes()).toEqual(["general", "pulse"]);
});
it("maps an unmapped metric to general only", async () => {
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general"]);
});
it("dedupes scopes across a mixed batch", async () => {
await invalidateStatusInsightsForTypes("u1", [
"WEIGHT",
"PULSE",
"BLOOD_GLUCOSE",
]);
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
expect(deletedScopes()).toEqual([
"bmi",
"general",
"pulse",
"weight",
]);
});
it("is a no-op for an empty type set (no DB call)", async () => {
await invalidateStatusInsightsForTypes("u1", []);
expect(deleteMany).not.toHaveBeenCalled();
});
});
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -22,19 +22,50 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
function medFixture(now: Date) {
return {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
});
describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} compliance series per medication spanning 3 years", async () => {
describe("generateMedicationComplianceStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} compliance series per medication", async () => {
const now = new Date();
const medication = {
id: "med-1",
@@ -42,7 +73,9 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 1100 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
const events: Array<{
@@ -73,17 +106,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK","medications":[]}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK","medications":[]}', captured);
await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
@@ -95,142 +118,92 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
expect(snapshot.medications).toBeInstanceOf(Array);
expect(snapshot.medications.length).toBe(1);
const dailySeries = snapshot.medications[0].dailySeries;
expect(dailySeries).toHaveProperty("daily");
expect(dailySeries).toHaveProperty("recent");
expect(dailySeries).toHaveProperty("weekly");
expect(dailySeries).toHaveProperty("monthly");
expect(dailySeries.daily.length).toBeGreaterThan(0);
expect(dailySeries.monthly.length).toBeGreaterThan(0);
expect(dailySeries.daily[0]).toHaveProperty("dayOffset");
expect(dailySeries.daily[0]).toHaveProperty("value");
expect(dailySeries.daily[0]).toHaveProperty("n");
expect(dailySeries.monthly[0]).toHaveProperty("monthOffset");
expect(dailySeries.monthly[0]).toHaveProperty("value");
expect(dailySeries.monthly[0]).toHaveProperty("n");
expect(dailySeries).toHaveProperty("yearly");
expect(dailySeries.recent.length).toBeLessThanOrEqual(21);
expect(dailySeries.recent[0]).toHaveProperty("date");
expect(dailySeries.recent[0]).toHaveProperty("mean");
expect(dailySeries.monthly[0]).toHaveProperty("month");
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMedicationComplianceStatusForUser — timeout never persists", () => {
it("serves the fallback summary without writing a cache row on timeout", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
createdAt: now,
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// Route returns the richer `{summary, medications}` shape — the
// helper writes the persist row and the route maps `summary` ←
// `text` on the way out.
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.summary).toBe("Medication compliance fallback…");
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}');
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.summary).toBe("Fresh compliance assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
it("strips metric: tokens out of the cached summary", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -239,24 +212,15 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
model: "x",
tokensUsed: 1,
})),
} as never);
// The compliance prompt returns only a `{ summary }` envelope; the
// per-medication cards are placeholder text built server-side, never
// model-authored. So token-leak hardening only has to scrub `summary`.
stubCompletion(
JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
}),
);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
@@ -264,6 +228,9 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
expect(result.summary).toBeTruthy();
expect(result.summary).not.toContain("metric:");
// A per-medication placeholder row is still surfaced for the active med.
expect(result.medications).toHaveLength(1);
expect(result.medications[0].medicationId).toBe("med-1");
expect(result.medications[0].text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
+79 -109
View File
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +18,35 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateMoodStatusForUser } from "../mood-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} mood series spanning 3 years", async () => {
describe("generateMoodStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} mood series, not the full daily array", async () => {
const now = new Date();
const entries: Array<{
date: string;
@@ -39,9 +57,6 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
for (let day = 0; day < 1000; day++) {
const t = new Date(now.getTime() - day * dayMs);
entries.push({
// production code keys mood buckets by `moodLoggedAt`, so the
// `date` string here is irrelevant for bucketing — keep it
// ISO-shaped so other downstream consumers don't choke.
date: t.toISOString().slice(0, 10),
score: 3 + (day % 3) * 0.5,
tags: [],
@@ -57,17 +72,7 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -75,110 +80,82 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const mood = snapshot.mood.series;
expect(mood).toHaveProperty("daily");
expect(mood).toHaveProperty("recent");
expect(mood).toHaveProperty("weekly");
expect(mood).toHaveProperty("monthly");
expect(mood.daily.length).toBeGreaterThan(0);
expect(mood.monthly.length).toBeGreaterThan(0);
expect(mood.daily[0]).toHaveProperty("dayOffset");
expect(mood.daily[0]).toHaveProperty("value");
expect(mood.daily[0]).toHaveProperty("n");
expect(mood.monthly[0]).toHaveProperty("monthOffset");
expect(mood.monthly[0]).toHaveProperty("value");
expect(mood.monthly[0]).toHaveProperty("n");
expect(mood).toHaveProperty("yearly");
expect(mood.recent.length).toBeLessThanOrEqual(21);
expect(mood.recent[0]).toHaveProperty("date");
expect(mood.recent[0]).toHaveProperty("mean");
expect(mood.monthly[0]).toHaveProperty("month");
const total =
mood.recent.length +
mood.weekly.length +
mood.monthly.length +
mood.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMoodStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: t.toISOString().slice(0, 10),
score: 4,
tags: [],
moodLoggedAt: t,
},
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMoodStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Mood fallback text…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMoodStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh mood assessment."}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh mood assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
@@ -191,14 +168,7 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"summary":"Mood stayed positive. metric:MOOD"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -0,0 +1,139 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* Wave 1D — payload-budget verification. A dense daily weigher (2 years
* of daily WEIGHT + BP, plus a many-type Apple-Health account for
* general-status) used to embed the full ~360-day daily array per
* metric, running ~20-100 K tokens. After the graded-series rewrite the
* snapshot the prompt carries must sit well under the ~24 000-char
* (~6 000-token) cap.
*
* The snapshot JSON is the `{ … }` block embedded in the user prompt;
* `runStatusCompletion` is stubbed to capture it.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
import { generateGeneralStatusForUser } from "../general-status";
const dayMs = 24 * 60 * 60 * 1000;
const BUDGET_CHARS = 24_000;
let capturedPrompt: string | null = null;
function stubCapture() {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
capturedPrompt = args.userPrompt;
return {
kind: "ok",
content: '{"summary":"OK"}',
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
function snapshotChars(): number {
const match = capturedPrompt!.match(/\{[\s\S]*\}/);
return match![0].length;
}
beforeEach(() => {
vi.resetAllMocks();
capturedPrompt = null;
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
heightCm: 175,
} as never);
stubCapture();
});
describe("status snapshot payload budget", () => {
it("weight-status: a 2-year daily weigher + daily BP stays under the char cap", async () => {
const now = new Date();
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
for (let day = 0; day < 730; day++) {
const measuredAt = new Date(now.getTime() - day * dayMs);
rows.push({ type: "WEIGHT", value: 80 + (day % 5), measuredAt });
rows.push({
type: "BLOOD_PRESSURE_SYS",
value: 120 + (day % 10),
measuredAt,
});
rows.push({
type: "BLOOD_PRESSURE_DIA",
value: 80 + (day % 5),
measuredAt,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
await generateWeightStatusForUser("user-1", { locale: "en" });
const chars = snapshotChars();
// Pre-rewrite this ran ~80-110 KB for this exact account; the
// graded rewrite lands it around ~15 KB.
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
});
it("general-status: a many-type dense account stays under the char cap", async () => {
const now = new Date();
const types = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"STEPS",
];
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
for (let day = 0; day < 730; day++) {
const measuredAt = new Date(now.getTime() - day * dayMs);
for (const type of types) {
rows.push({ type, value: 50 + (day % 30), measuredAt });
}
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
await generateGeneralStatusForUser("user-1", { locale: "en" });
const chars = snapshotChars();
// Pre-rewrite a many-type dense account could exceed 100 K tokens;
// the graded rewrite lands it around ~12 KB.
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
});
});
@@ -1,19 +1,15 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* v1.4.28 R3a FB-D2 — provider timeout fallback for the pulse-status
* route. When the upstream `generateCompletion` call does not resolve
* inside the 20-second budget the route returns a deterministic
* cached-style envelope so the InsightStatusCard renders the
* fallback text instead of spinning behind React-Query's default
* retries.
*
* v1.4.41 — the timeout branch now persists a sentinel keyed to
* today (see `persistTimeoutStubAndReturn`) so subsequent mounts
* short-circuit at the cache lookup. The persisted row carries
* `timeout: true` + `model: "timeout-stub"` so the daily pre-warm
* job can recognise and overwrite the stub instead of treating it
* as a real assessment.
* Provider-timeout fallback for the pulse-status route. When the
* provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS`
* the route returns the deterministic no-key fallback so the
* InsightStatusCard renders instead of spinning — but it must NOT
* persist the fallback. The earlier behaviour cached a `timeout-stub`
* row keyed to today; that stub stuck until midnight and hid the real
* data-driven assessment. The fix treats a timeout as a transient miss:
* serve the fallback for this render, write nothing, re-attempt next
* mount.
*/
vi.mock("@/lib/db", () => ({
@@ -21,12 +17,13 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -35,20 +32,16 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generatePulseStatusForUser — provider timeout fallback", () => {
it("returns a cached-style envelope when the provider exceeds 20 s", async () => {
it("returns the fallback without persisting a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
@@ -58,40 +51,21 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
// Provider stalls indefinitely.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise<{ content: string }>(() => {}),
),
} as never);
const promise = generatePulseStatusForUser("user-1", { locale: "en" });
// Advance past the 20-second budget so the timeout race wins.
await vi.advanceTimersByTimeAsync(21_000);
const result = await promise;
const result = await generatePulseStatusForUser("user-1", {
locale: "en",
});
expect(result.hasProvider).toBe(true);
expect(result.cached).toBe(true);
expect(typeof result.text).toBe("string");
expect(result.text?.length ?? 0).toBeGreaterThan(0);
// v1.4.41 — the timeout branch now persists a sentinel row so
// subsequent mounts short-circuit at the cache lookup. The
// daily pre-warm worker overwrites the stub by detecting
// `timeout: true` + `model: "timeout-stub"`.
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
};
const parsed = JSON.parse(createArg.data.details) as {
timeout?: boolean;
model?: string;
};
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// No persisted row — the sticky-stub bug is gone.
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
+70 -69
View File
@@ -5,12 +5,13 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -19,17 +20,38 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the pulse graded series folds monthly/yearly from
// the full-history `measurement.findMany` fallback on a tier miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} pulse series with dayOffset/monthOffset/value/n", async () => {
describe("generatePulseStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} pulse series, not the full daily array", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 1000; day++) {
@@ -51,17 +73,7 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -69,63 +81,58 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const pulse = snapshot.pulse.series;
expect(pulse).toHaveProperty("daily");
expect(pulse).toHaveProperty("recent");
expect(pulse).toHaveProperty("weekly");
expect(pulse).toHaveProperty("monthly");
expect(pulse.daily.length).toBeGreaterThan(0);
expect(pulse.monthly.length).toBeGreaterThan(0);
expect(pulse.daily[0]).toHaveProperty("dayOffset");
expect(pulse.daily[0]).toHaveProperty("value");
expect(pulse.daily[0]).toHaveProperty("n");
expect(pulse.monthly[0]).toHaveProperty("monthOffset");
expect(pulse.monthly[0]).toHaveProperty("value");
expect(pulse.monthly[0]).toHaveProperty("n");
expect(pulse).toHaveProperty("yearly");
expect(pulse.recent.length).toBeLessThanOrEqual(21);
expect(pulse.recent[0]).toHaveProperty("date");
expect(pulse.recent[0]).toHaveProperty("mean");
expect(pulse.monthly[0]).toHaveProperty("month");
const total =
pulse.recent.length +
pulse.weekly.length +
pulse.monthly.length +
pulse.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
// The persist-on-timeout path is covered by
// `pulse-status-timeout.test.ts`. This block pins the second-mount
// behaviour: when a sentinel row already exists for today the cache
// lookup short-circuits and the provider is never invoked.
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
timeout: true,
}),
};
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh pulse assessment."}');
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Pulse fallback text…");
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh pulse assessment.");
expect(result.cached).toBe(false);
});
});
@@ -144,15 +151,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
);
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -0,0 +1,123 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { isTimeoutStub, readFreshStatusText } from "../status-cache";
const TODAY = "2026-05-31";
function cacheRow(details: Record<string, unknown>, createdAt = new Date()) {
return { createdAt, details: JSON.stringify(details) };
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("isTimeoutStub", () => {
it("flags the timeout-stub model marker", () => {
expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true);
});
it("flags the timeout:true marker", () => {
expect(isTimeoutStub({ timeout: true })).toBe(true);
});
it("does not flag a real assessment", () => {
expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false);
expect(isTimeoutStub({})).toBe(false);
});
});
describe("readFreshStatusText", () => {
it("returns today's real assessment text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Your weight trend is stable.",
model: "gpt-4o-mini",
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit?.text).toBe("Your weight trend is stable.");
});
it("skips a timeout-stub row keyed to today", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips a stale-day row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips an empty-text row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: TODAY, text: " " }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("does not read the cache under force", async () => {
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: true,
});
expect(hit).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
});
it("treats a malformed payload as a miss", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: "{not json",
} as never);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
});
+92 -102
View File
@@ -4,12 +4,13 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +19,38 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the graded builder folds monthly/yearly from the
// full-history `measurement.findMany` fallback the test already mocks.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} weight series with bucket fields", async () => {
describe("generateWeightStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} weight series, not the full daily array", async () => {
const now = new Date();
const records: Array<{
type: string;
@@ -51,17 +73,7 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateWeightStatusForUser("user-1", { locale: "en" });
@@ -69,21 +81,31 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const weight = snapshot.weight.series;
expect(weight).toHaveProperty("daily");
expect(weight).toHaveProperty("recent");
expect(weight).toHaveProperty("weekly");
expect(weight).toHaveProperty("monthly");
expect(weight.daily.length).toBeGreaterThan(0);
expect(weight.monthly.length).toBeGreaterThan(0);
expect(weight.daily[0]).toHaveProperty("dayOffset");
expect(weight.daily[0]).toHaveProperty("value");
expect(weight.daily[0]).toHaveProperty("n");
expect(weight.monthly[0]).toHaveProperty("monthOffset");
expect(weight.monthly[0]).toHaveProperty("value");
expect(weight.monthly[0]).toHaveProperty("n");
expect(weight).toHaveProperty("yearly");
// No raw daily array beyond the bounded recent window.
expect(weight.recent.length).toBeLessThanOrEqual(21);
expect(weight.recent[0]).toHaveProperty("date");
expect(weight.recent[0]).toHaveProperty("mean");
expect(weight.recent[0]).toHaveProperty("min");
expect(weight.recent[0]).toHaveProperty("max");
expect(weight.monthly[0]).toHaveProperty("month");
expect(weight.monthly[0]).toHaveProperty("mean");
// The whole graded series collapses 1000 daily readings to a tiny
// bucket count.
const total =
weight.recent.length +
weight.weekly.length +
weight.monthly.length +
weight.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateWeightStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
@@ -93,83 +115,57 @@ describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
// Provider stalls indefinitely so the timeout race wins.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateWeightStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Weight is a directional metric…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Weight is a directional metric…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateWeightStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
@@ -181,15 +177,9 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
+101 -153
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -28,59 +27,32 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Cap on the embedded correlation / paired-daily arrays. The Pearson
* coefficients stay computed over the full overlap; only the row-by-row
* lists the prompt carries are trimmed to their most recent entries.
*/
const CORRELATION_PAIR_CAP = 30;
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
@@ -134,13 +106,18 @@ export async function generateBloodPressureStatusForUser(
const cacheAction = `insights.blood-pressure-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -151,35 +128,6 @@ export async function generateBloodPressureStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further; this `take` keeps the read
// bounded for Apple-Health-rich accounts. BP captures three types
@@ -397,6 +345,27 @@ export async function generateBloodPressureStatusForUser(
);
const moodMean = moodSummary?.mean ?? null;
// Compact graded series for the prompt. The `*Series.daily` buckets
// above stay for the correlation pairing + in-target gate; only the
// graded shape is embedded so the full daily arrays never ship.
const gradedFromDaily = (buckets: DailyBucket[]) =>
buildGradedSeriesFromPoints(
buckets.map((b) => ({
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
value: b.value,
})),
now,
);
// The two BP channels source their recent / weekly slices from a
// bounded raw read and their monthly / yearly tail from the MONTH /
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
const [sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
]);
const moodGraded = gradedFromDaily(moodSeries.daily);
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
.map((entry) => {
if (entry.continuityPct == null) return null;
@@ -446,13 +415,13 @@ export async function generateBloodPressureStatusForUser(
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: sysSeries,
series: sysGraded,
},
diastolic: {
summary: summarizeSeries(
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: diaSeries,
series: diaGraded,
},
paired: {
summary: summarizeSeries(
@@ -460,7 +429,7 @@ export async function generateBloodPressureStatusForUser(
value: (entry.sys + entry.dia) / 2,
})),
),
series: pairedBloodPressure,
series: pairedBloodPressure.slice(-CORRELATION_PAIR_CAP),
},
targets: bpTargets
? {
@@ -472,7 +441,7 @@ export async function generateBloodPressureStatusForUser(
},
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs.map((entry) => ({
pairs: weightVsSystolicPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
systolic: round(entry.b, 2),
@@ -482,7 +451,7 @@ export async function generateBloodPressureStatusForUser(
expectedIntakesPerDay: expectedBpIntakesPerDay,
medicationCount: bpMedications.length,
correlation: continuityVsSystolicCorrelation,
series: continuityVsSystolicSeries,
series: continuityVsSystolicSeries.slice(-CORRELATION_PAIR_CAP),
},
bpMedications: medicationCompliance,
moodContext:
@@ -491,7 +460,7 @@ export async function generateBloodPressureStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
moodVsSystolicCorrelation: (() => {
const moodVsSysPairs = pairDailyBuckets(
moodSeries.daily,
@@ -504,11 +473,17 @@ export async function generateBloodPressureStatusForUser(
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -522,86 +497,59 @@ export async function generateBloodPressureStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBloodPressureStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for blood-pressure-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error(
"Blood-pressure-status summary was empty after normalization",
);
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+90 -169
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import { classifyBMI } from "@/lib/analytics/classifications";
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -8,61 +7,26 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesWithRollups,
scaleGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path and is the same anti-pattern fixed in `summarize()` (see
// `.planning/round-v1433-analytics-500-report.md` §"Carry-over").
// These helpers feed off bounded windows today so they did not crash,
// but the spread allocates a transient args array on every call;
// folding once is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
export async function generateBmiStatusForUser(
userId: string,
options?: {
@@ -80,13 +44,18 @@ export async function generateBmiStatusForUser(
const cacheAction = `insights.bmi-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -97,35 +66,6 @@ export async function generateBmiStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
if (!user?.heightCm || user.heightCm <= 0) {
return {
hasProvider: true,
@@ -134,7 +74,7 @@ export async function generateBmiStatusForUser(
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
: "BMI assessment currently requires height data in the profile.",
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -157,26 +97,28 @@ export async function generateBmiStatusForUser(
.then((rows) => rows.reverse());
const now = new Date();
const weightSeries = applyPayloadBudget(
measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now },
);
const heightFactor = (user.heightCm / 100) ** 2;
const bmiPoints = measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: round(measurement.value / heightFactor, 2),
}));
// `applyPayloadBudget` daily buckets drive the latest/previous focus;
// the compact graded series is what reaches the prompt.
const weightSeries = applyPayloadBudget(bmiPoints, { now });
// BMI has no rollup tier of its own — it is weight ÷ height², a linear
// transform by the per-user height factor. Read the WEIGHT tier (recent
// / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR)
// and scale by 1 / heightFactor; the scaled series is exact.
const weightGraded = await buildGradedSeriesWithRollups(
userId,
"WEIGHT",
now,
);
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
const bmiSeries = {
daily: weightSeries.daily.map((bucket) => ({
dayOffset: bucket.dayOffset,
value: round(bucket.value / heightFactor, 2),
n: bucket.n,
})),
monthly: weightSeries.monthly.map((bucket) => ({
monthOffset: bucket.monthOffset,
value: round(bucket.value / heightFactor, 2),
n: bucket.n,
})),
daily: weightSeries.daily,
monthly: weightSeries.monthly,
};
// daily[0] = newest bucket (lowest dayOffset).
@@ -216,7 +158,7 @@ export async function generateBmiStatusForUser(
summary: summarizeSeries(
bmiSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: bmiSeries,
series: bmiGraded,
latestDayFocus: latestBmi
? {
dayOffset: latestBmi.dayOffset,
@@ -235,11 +177,17 @@ export async function generateBmiStatusForUser(
},
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -253,84 +201,57 @@ export async function generateBmiStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBmiStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for bmi-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Bmi-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ import type { CoachScopeSource } from "@/lib/ai/coach/types";
*
* Until v1.4.27 the Coach drawer was mounted inline on
* `/insights/page.tsx` body, which meant navigating to a routed
* sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
* sub-page (`/insights/blood-pressure`, etc.) unmounted the drawer and
* removed every entry point to the Coach. The MA3 audit + Decision F
* promoted the drawer up to `/insights/layout.tsx` so every routed
* Insights surface can launch it.
@@ -157,6 +157,82 @@ export async function evictPerStatusInsightCache(
});
}
/**
* The seven per-metric assessment scopes. Each generator persists its
* cached text under `insights.<scope>-status.<locale>`; the warm-pass in
* the nightly cron re-fills these after `evictPerStatusInsightCache`
* clears them, and the targeted invalidator below drops only the scopes
* a fresh measurement of a given type actually dirties.
*/
export const PER_STATUS_SCOPES = [
"blood-pressure",
"pulse",
"weight",
"bmi",
"mood",
"medication-compliance",
"general",
] as const;
export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
/**
* Map a measurement type to the assessment scopes a fresh reading of it
* dirties. `general` is the catch-all overview, so every measurement
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
* new weight reading invalidates both the weight and the BMI card.
*/
function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] {
switch (type) {
case "WEIGHT":
return ["weight", "bmi", "general"];
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return ["blood-pressure", "general"];
case "PULSE":
case "RESTING_HEART_RATE":
return ["pulse", "general"];
default:
// Every other tracked metric (body composition, sleep, steps,
// glucose, …) still feeds the general overview assessment.
return ["general"];
}
}
/**
* Drop the cached per-metric assessment rows that a batch of fresh
* measurements dirties, so the next mount (or the next nightly warm
* pass) regenerates them against the new data instead of serving the
* pre-measurement text for the rest of the day.
*
* Fire-and-forget from the measurement ingest path — idempotent (a
* redundant delete costs nothing) and never a blocker on the user's
* write. Deletes every locale variant of each affected scope in one
* sweep; the `-status.` substring guard keeps it from touching the
* comprehensive cache or any unrelated `insights.*` audit row.
*/
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
}
if (scopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
},
});
}
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: "de" | "en";
+101 -159
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -13,63 +12,35 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
const MEASUREMENT_TYPES = measurementTypeEnum.options;
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
// The general overview folds its per-type graded series in memory from a
// single bounded read (take 5000, desc) rather than per-type rollup-tier
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
// caps the multi-metric snapshot, so the bounded in-memory fold is the
// deliberate design here; the rollup tier is wired into the focused
// single-metric cards (weight / pulse / bmi / blood-pressure) instead.
export async function generateGeneralStatusForUser(
userId: string,
@@ -88,13 +59,18 @@ export async function generateGeneralStatusForUser(
const cacheAction = `insights.general-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -105,35 +81,6 @@ export async function generateGeneralStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. General-status pulls
// every supported measurement type; without a cap the read scales
// linearly with account density.
@@ -157,25 +104,39 @@ export async function generateGeneralStatusForUser(
const now = new Date();
// `dailyByType` keeps the `applyPayloadBudget` daily buckets for the
// downstream BP in-target pairing; the prompt embeds the compact
// graded series instead of the full daily array. Skip types with no
// data so a many-metric account can't balloon the snapshot with empty
// series objects.
const dailyByType = new Map<
(typeof MEASUREMENT_TYPES)[number],
ReturnType<typeof applyPayloadBudget>
>();
const measurementSeries = Object.fromEntries(
MEASUREMENT_TYPES.map((type) => {
MEASUREMENT_TYPES.flatMap((type) => {
const records = measurements
.filter((measurement) => measurement.type === type)
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
}));
if (records.length === 0) return [];
const series = applyPayloadBudget(records, { now });
dailyByType.set(type, series);
const graded = buildGradedSeriesFromPoints(records, now);
return [
type,
{
summary: summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
),
series,
},
[
type,
{
summary: summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
),
series: graded,
},
] as const,
];
}),
);
@@ -222,6 +183,7 @@ export async function generateGeneralStatusForUser(
value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0,
}));
const adherenceSeries = applyPayloadBudget(adherenceRecords, { now });
const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now);
// Fetch mood context (optional — for enrichment only). v1.4.28
// FB-D2 — cap at 90 entries.
@@ -239,6 +201,7 @@ export async function generateGeneralStatusForUser(
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodRecords, { now });
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -247,9 +210,9 @@ export async function generateGeneralStatusForUser(
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
let bpInTargetLast30Days: number | null = null;
if (bpTargets) {
const sysDaily = measurementSeries.BLOOD_PRESSURE_SYS?.series.daily ?? [];
const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? [];
const diaMap = new Map(
(measurementSeries.BLOOD_PRESSURE_DIA?.series.daily ?? []).map(
(dailyByType.get("BLOOD_PRESSURE_DIA")?.daily ?? []).map(
(bucket) => [bucket.dayOffset, bucket.value] as const,
),
);
@@ -312,7 +275,7 @@ export async function generateGeneralStatusForUser(
summary: summarizeSeries(
adherenceSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: adherenceSeries,
series: adherenceGraded,
},
bloodPressureTargets: bpTargets
? {
@@ -327,16 +290,22 @@ export async function generateGeneralStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
}
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
// v1.4: pull the previous cached general-status into the prompt so
@@ -353,84 +322,57 @@ export async function generateGeneralStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for general-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("General-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+544
View File
@@ -0,0 +1,544 @@
/**
* Shared graded-series compressor for AI insight snapshots.
*
* Background — see `.planning/research/ai-payload-compression-audit.md`.
* The seven per-card status generators embedded the FULL daily array
* per metric (`bucketSeries`, up to ~360 daily buckets + ~24 monthly)
* plus correlation pair arrays, and originally never touched the WEEK /
* MONTH / YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran
* ~23 KB for weight alone; `general-status` across many Apple-Health
* types could blow past 100 K tokens. The Coach is the one path that
* already grades its timeline (recent days verbatim → weekly means →
* bounded window).
*
* As of v1.8.0 the focused single-metric cards (weight / pulse / bmi /
* blood-pressure) source their monthly / yearly slices from the MONTH /
* YEAR rollup tier via `buildGradedSeriesWithRollups`, so the coarse
* tiers finally earn their write amplification. The multi-metric
* `general-status` and the tier-less mood / adherence series stay on the
* bounded in-memory fold (`buildGradedSeriesFromPoints`).
*
* This module is the single graded shape every status path uses:
*
* recent last ~21 days daily min/max/mean/n (~14-21 rows)
* weekly the ~10 weeks before ISO wk min/max/mean/n (~8-10 rows)
* monthly the ~12 months before month min/max/mean (~9-12 rows)
* yearly everything older year mean/slope (~1-3 rows)
*
* ~30-45 rows per metric regardless of history depth, never an
* individual reading beyond `recent`.
*
* Two entry points:
* - `buildGradedSeriesFromPoints(points, now)` — pure, in-memory. The
* recent / weekly / monthly slices fold raw points; the yearly slice
* carries a least-squares slope. Used where the caller already holds
* the rows (and by the tests).
* - `buildGradedSeriesWithRollups(userId, type, now)` — sources the
* monthly / yearly slices from the pre-aggregated MONTH / YEAR
* rollup tier (`readBestGranularityRollups`) and the recent / weekly
* slices from a bounded raw read, so the coarse tiers finally earn
* their write amplification.
*
* The recent / weekly bucketing mirrors the Coach
* (`coach/snapshot.ts:bucketWeekly` + `buildDailyValueRows`). Those
* helpers are intentionally duplicated rather than lifted out of the
* Coach: the Coach contract must not move under it, and the shapes here
* carry min/max (the Coach only needs the mean). The two are small and
* independently testable.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { toBerlinYmd } from "@/lib/tz/resolver";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Days kept verbatim at daily granularity. */
const RECENT_DAYS = 21;
/** ISO weeks folded after the recent window. */
const WEEKLY_WEEKS = 10;
/** Calendar months folded after the weekly window. */
const MONTHLY_MONTHS = 12;
const RECENT_WINDOW_MS = RECENT_DAYS * MS_PER_DAY;
const WEEKLY_WINDOW_MS = (RECENT_DAYS + WEEKLY_WEEKS * 7) * MS_PER_DAY;
// Approximate the monthly horizon in days; the precise calendar-month
// boundary is handled by the YMD key, this only separates monthly from
// yearly buckets.
const MONTHLY_WINDOW_MS =
WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
export interface RecentDayBucket {
/** Berlin YYYY-MM-DD. */
date: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface WeeklyBucket {
/** ISO week key like 2026-W19 (Berlin-anchored). */
weekISO: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface MonthlyBucket {
/** Berlin YYYY-MM. */
month: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface YearlyBucket {
/** Berlin YYYY. */
year: string;
min: number;
max: number;
mean: number;
n: number;
/** Least-squares slope per reading order (null when < 2 points). */
slope: number | null;
}
export interface GradedSeries {
recent: RecentDayBucket[];
weekly: WeeklyBucket[];
monthly: MonthlyBucket[];
yearly: YearlyBucket[];
}
interface Point {
measuredAt: Date;
value: number;
}
function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function berlinDayKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
return `${year}-${month}-${day}`;
}
function berlinMonthKey(date: Date): string {
const { year, month } = toBerlinYmd(date);
return `${year}-${month}`;
}
function berlinYearKey(date: Date): string {
return toBerlinYmd(date).year;
}
/**
* ISO week key (Berlin-anchored). Mirrors the Coach's `isoWeekKey` but
* fixed to the user's Berlin display day so the week label agrees with
* the rest of the snapshot.
*/
function berlinIsoWeekKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
const localMidnight = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day)),
);
const dayNum = localMidnight.getUTCDay() || 7;
localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((localMidnight.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7,
);
return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface Agg {
sum: number;
min: number;
max: number;
n: number;
}
function foldInto(map: Map<string, Agg>, key: string, value: number): void {
const existing = map.get(key);
if (existing) {
existing.sum += value;
existing.n += 1;
if (value < existing.min) existing.min = value;
if (value > existing.max) existing.max = value;
} else {
map.set(key, { sum: value, min: value, max: value, n: 1 });
}
}
/**
* Least-squares slope of `values` against their index (0..n-1). Returns
* null for fewer than two points. The yearly trend signal — direction
* over the order the readings landed.
*/
function leastSquaresSlope(values: number[]): number | null {
const n = values.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumXX += i * i;
}
const denom = n * sumXX - sumX * sumX;
if (denom === 0) return null;
return round((n * sumXY - sumX * sumY) / denom, 4);
}
/**
* Build the graded series purely in memory. `points` may carry several
* readings per day; same-period readings are folded into one bucket.
*/
export function buildGradedSeriesFromPoints(
points: Point[],
now: Date,
): GradedSeries {
const nowMs = now.getTime();
const recentAgg = new Map<string, Agg>();
const weeklyAgg = new Map<string, Agg>();
const monthlyAgg = new Map<string, Agg>();
// Yearly keeps ordered values for the slope, plus min/max/sum.
const yearlyValues = new Map<string, number[]>();
const yearlyAgg = new Map<string, Agg>();
for (const p of points) {
const ageMs = nowMs - p.measuredAt.getTime();
if (ageMs < 0) continue; // future reading — ignore
if (ageMs < RECENT_WINDOW_MS) {
foldInto(recentAgg, berlinDayKey(p.measuredAt), p.value);
} else if (ageMs < WEEKLY_WINDOW_MS) {
foldInto(weeklyAgg, berlinIsoWeekKey(p.measuredAt), p.value);
} else if (ageMs < MONTHLY_WINDOW_MS) {
foldInto(monthlyAgg, berlinMonthKey(p.measuredAt), p.value);
} else {
const yk = berlinYearKey(p.measuredAt);
foldInto(yearlyAgg, yk, p.value);
const list = yearlyValues.get(yk);
if (list) list.push(p.value);
else yearlyValues.set(yk, [p.value]);
}
}
const recent: RecentDayBucket[] = Array.from(recentAgg.entries())
.map(([date, a]) => ({
date,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.date.localeCompare(y.date));
const weekly: WeeklyBucket[] = Array.from(weeklyAgg.entries())
.map(([weekISO, a]) => ({
weekISO,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.weekISO.localeCompare(y.weekISO));
const monthly: MonthlyBucket[] = Array.from(monthlyAgg.entries())
.map(([month, a]) => ({
month,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.month.localeCompare(y.month));
const yearly: YearlyBucket[] = Array.from(yearlyAgg.entries())
.map(([year, a]) => ({
year,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
// Order the readings oldest → newest for the slope. The map kept
// insertion order; `points` arrives caller-ordered, so re-sort by
// pushing in chronological order is unnecessary — the slope is a
// direction signal, not a calibrated rate.
slope: leastSquaresSlope(yearlyValues.get(year) ?? []),
}))
.sort((x, y) => x.year.localeCompare(y.year));
return { recent, weekly, monthly, yearly };
}
/**
* Project a rollup bucket row to a monthly/yearly graded bucket. The
* tier already carries min/max/mean/slope per bucket — no JS folding.
*/
function rollupMonthly(rows: RollupBucketRow[]): MonthlyBucket[] {
return rows.map((r) => ({
month: berlinMonthKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
}));
}
function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
return rows.map((r) => ({
year: berlinYearKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
slope: r.slope === null ? null : round(r.slope, 4),
}));
}
/**
* Build the graded series with the recent / weekly slices folded from a
* bounded raw read and the monthly / yearly slices read from the
* pre-aggregated rollup tier.
*
* The recent / weekly read is capped to the recent + weekly horizon
* (last ~90 days), so the common warm-tier path never pulls the full
* multi-year history into memory. The rollup router picks MONTH for the
* ~1-year window and YEAR for the multi-year tail.
*
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
* coverage for a metric — a fresh account the boot-backfill has not yet
* caught up on — the bounded ~90-day read can NOT supply the monthly /
* yearly slices (every row in it folds into recent / weekly), so naively
* reusing it would ship empty monthly / yearly arrays even when years of
* raw history exist. On that miss this falls back to a full-history
* in-memory fold so the coarse slices are populated from the raw rows
* rather than left falsely empty. The full read only happens when the
* tier is cold, which is exactly the case the write-amplified tier was
* meant to avoid — so the hot path stays bounded and the cold path stays
* correct.
*/
export async function buildGradedSeriesWithRollups(
userId: string,
type: MeasurementType,
now: Date,
): Promise<GradedSeries> {
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
const rawRecent = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const recentGraded = buildGradedSeriesFromPoints(
rawRecent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
);
// Monthly slice: route a ~1-year window through the tier.
const monthlyRouted = await readBestGranularityRollups(userId, type, 365);
// Yearly slice: route a multi-year window through the tier.
const yearlyRouted = await readBestGranularityRollups(userId, type, 1095);
const monthlyCovered =
monthlyRouted !== null && monthlyRouted.granularity === "MONTH";
const yearlyCovered =
yearlyRouted !== null && yearlyRouted.granularity === "YEAR";
let monthly: MonthlyBucket[];
let yearly: YearlyBucket[];
if (monthlyCovered) {
// Drop the months already covered by recent/weekly (last ~90 d).
const cutoff = new Date(now.getTime() - WEEKLY_WINDOW_MS);
monthly = rollupMonthly(
monthlyRouted.rows.filter((r) => r.bucketStart < cutoff),
).slice(-MONTHLY_MONTHS);
} else {
monthly = [];
}
if (yearlyCovered) {
// Keep only years older than the monthly horizon.
const cutoff = new Date(now.getTime() - MONTHLY_WINDOW_MS);
yearly = rollupYearly(
yearlyRouted.rows.filter((r) => r.bucketStart < cutoff),
);
} else {
yearly = [];
}
// Tier coverage miss for one or both coarse slices: the bounded
// ~90-day read can't supply them, so fold the FULL history in memory
// and take whichever slices the tier left empty. The full read is the
// exception (cold-tier accounts), never the warm-tier norm.
if (!monthlyCovered || !yearlyCovered) {
const allRows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const fullGraded = buildGradedSeriesFromPoints(
allRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
);
if (!monthlyCovered) monthly = fullGraded.monthly;
if (!yearlyCovered) yearly = fullGraded.yearly;
}
return {
recent: recentGraded.recent,
weekly: recentGraded.weekly,
monthly,
yearly,
};
}
/**
* Apply a constant multiplier to every value in a graded series, keeping
* the bucket keys / counts intact. BMI = weight ÷ height² is a linear
* transform of weight by a per-user constant, so the BMI status card can
* read the WEIGHT rollup tier and scale it rather than maintaining a
* separate (non-existent) BMI tier — `mean / min / max / slope` all carry
* the factor exactly, so the scaled series is identical to folding the
* derived BMI points directly.
*/
export function scaleGradedSeries(
series: GradedSeries,
factor: number,
digits = 2,
): GradedSeries {
const r = (v: number) => round(v * factor, digits);
return {
recent: series.recent.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
weekly: series.weekly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
monthly: series.monthly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
yearly: series.yearly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
slope: b.slope === null ? null : round(b.slope * factor, 4),
})),
};
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
* pretty-printed form). The status snapshots had no cap at all; a
* many-metric `general-status` could balloon past 100 K tokens.
*/
export const MAX_SNAPSHOT_CHARS = 24_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
* `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the
* prompt ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
* (correlation pair arrays, paired-daily rows, …),
* 4. drop every graded `recent` array (last resort — the coarse
* monthly summary survives).
*
* Mutates `snapshot` in place and returns the slices it shed so the
* caller can annotate. Generic by design: it keys off the graded field
* names + array length, never off a specific generator's shape.
*/
export function degradeStatusSnapshotToBudget(
snapshot: Record<string, unknown>,
arrayTailCap = 30,
): string[] {
const shed: string[] = [];
const size = () => JSON.stringify(snapshot, null, 2).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const walk = (
node: unknown,
fn: (parent: Record<string, unknown>, key: string, value: unknown) => void,
): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item, fn);
return;
}
if (node && typeof node === "object") {
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
fn(obj, key, obj[key]);
walk(obj[key], fn);
}
}
};
const dropField = (field: string) => {
walk(snapshot, (parent, key) => {
if (key === field && Array.isArray(parent[key])) {
delete parent[key];
}
});
};
dropField("yearly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("yearly");
return shed;
}
shed.push("yearly");
dropField("weekly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("weekly");
return shed;
}
shed.push("weekly");
// Truncate every remaining non-graded array to its tail.
walk(snapshot, (parent, key, value) => {
if (
Array.isArray(value) &&
key !== "recent" &&
value.length > arrayTailCap
) {
parent[key] = value.slice(-arrayTailCap);
}
});
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("array-tails");
return shed;
}
shed.push("array-tails");
dropField("recent");
shed.push("recent");
return shed;
}
+109 -171
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
@@ -18,12 +17,18 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
round,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { isTimeoutStub } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -31,26 +36,11 @@ import { toBerlinDayKey } from "@/lib/tz/resolver";
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
type SupportedLocale = "de" | "en";
interface MedicationSummaryItem {
medicationId: string;
text: string;
}
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
export async function generateMedicationComplianceStatusForUser(
userId: string,
options?: {
@@ -69,73 +59,50 @@ export async function generateMedicationComplianceStatusForUser(
const cacheAction = `insights.medication-compliance-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
if (
parsed.dateKey === todayKey &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
// keeps its own cache-read — but it shares the stub-rejection
// predicate so a timeout stub never sticks for the day.
if (!force) {
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
model?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
}
// v1.4.41 — timeout-stub rows persist `text` + `timeout: true`
// instead of the standard `summary` + `medications` envelope.
// Recognise the stub so the second mount short-circuits at the
// cache lookup instead of re-racing the provider call.
if (
parsed.dateKey === todayKey &&
parsed.timeout === true &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
summary: parsed.text,
medications: [],
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
if (
parsed.dateKey === todayKey &&
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
} catch {
// ignore invalid cache payload
}
}
@@ -154,7 +121,7 @@ export async function generateMedicationComplianceStatusForUser(
: "There are currently no active medications configured.",
medications: [],
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -244,8 +211,11 @@ export async function generateMedicationComplianceStatusForUser(
value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1),
}));
const dailySeries = applyPayloadBudget(perDayRecords, { now });
const latestDay = dailySeries.daily[0] ?? null;
// `applyPayloadBudget` gives the latest-day focus; the compact
// graded series is what reaches the prompt.
const dailyBudgeted = applyPayloadBudget(perDayRecords, { now });
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now);
const latestDay = dailyBudgeted.daily[0] ?? null;
return {
medicationId: medication.id,
@@ -315,11 +285,17 @@ export async function generateMedicationComplianceStatusForUser(
medications: medicationSnapshots,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -333,100 +309,62 @@ export async function generateMedicationComplianceStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
// This route returns a richer shape (`summary`, `medications`)
// than the standard `text` envelope, so the helper is called
// for its persist side-effect and the route maps `summary` ←
// `text` on the way out.
const stubReturn = await persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
// Transient miss — serve the fallback for this render without
// persisting it, so the next mount re-attempts a real generation.
returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMedicationComplianceStatusText(locale),
});
return {
hasProvider: true,
summary: stubReturn.text,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: stubReturn.updatedAt,
updatedAt: null,
};
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error(
"AI returned empty content for medication-compliance-status",
);
}
let summary = "";
let medicationSummaries: MedicationSummaryItem[] = [];
try {
const parsed = JSON.parse(content) as {
summary?: string;
medications?: Array<{ medicationId?: string; summary?: string }>;
};
summary = typeof parsed.summary === "string" ? parsed.summary : content;
const incomingMap = new Map<string, string>();
for (const entry of parsed.medications ?? []) {
if (
typeof entry?.medicationId === "string" &&
typeof entry?.summary === "string" &&
entry.summary.trim().length > 0
) {
incomingMap.set(entry.medicationId, entry.summary);
}
}
medicationSummaries = medicationSnapshots.map((medication) => ({
// The compliance prompt returns a single `{ summary }` envelope — it
// does not emit per-medication text. The per-medication cards carry a
// placeholder so the UI surfaces a row per active medication; the
// overall `summary` is the model-authored assessment.
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map(
(medication) => ({
medicationId: medication.medicationId,
text: normalizeSummaryText(
incomingMap.get(medication.medicationId) ??
(locale === "de"
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`),
),
}));
} catch {
summary = content;
medicationSummaries = medicationSnapshots.map((medication) => ({
medicationId: medication.medicationId,
text:
locale === "de"
? `${medication.name}: Die medikamentenspezifische Kurzbewertung konnte heute nicht separat aufbereitet werden.`
: `${medication.name}: The medication-specific short assessment could not be prepared separately today.`,
}));
}
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`,
),
}),
);
summary = normalizeSummaryText(summary);
if (!summary) {
throw new Error(
"Medication-compliance-status summary was empty after normalization",
@@ -442,9 +380,9 @@ export async function generateMedicationComplianceStatusForUser(
locale,
summary,
medications: medicationSummaries,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+78 -158
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
@@ -15,60 +14,25 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
@@ -118,43 +82,19 @@ export async function generateMoodStatusForUser(
const cacheAction = `insights.mood-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
@@ -179,13 +119,14 @@ export async function generateMoodStatusForUser(
const now = new Date();
const moodSeries = applyPayloadBudget(
entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
})),
{ now },
);
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
// and correlations; the compact graded series reaches the prompt.
const moodSeries = applyPayloadBudget(moodPoints, { now });
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -329,7 +270,7 @@ export async function generateMoodStatusForUser(
},
mood: {
summary: moodSummary,
series: moodSeries,
series: moodGraded,
latestDayFocus: latestMood
? {
dayOffset: latestMood.dayOffset,
@@ -391,11 +332,17 @@ export async function generateMoodStatusForUser(
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -409,84 +356,57 @@ export async function generateMoodStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMoodStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for mood-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Mood-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
-73
View File
@@ -1,73 +0,0 @@
import { prisma } from "@/lib/db";
/**
* v1.4.37 / v1.4.41 — shared timeout-stub persist for InsightStatus routes.
*
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
* the caller persists a sentinel `auditLog` row keyed to today's Berlin
* day so the next mount short-circuits at the cache lookup instead of
* re-racing the same 20 s provider call on every cold visit. Without
* this, a single stall (provider hiccup, network blip, model warm-up)
* leaves the user staring at the loading state on every reload until
* the daily 02:20 pre-warm job runs.
*
* The persisted body is the deterministic no-key fallback the caller
* would have returned in the bare-fallback shape, so the user-facing
* UI is identical; only the re-fire frequency drops to zero for the
* rest of the day.
*
* `meta.timeout` + `meta.model = "timeout-stub"` are set so the daily
* pg-boss pre-warm worker (the same job that originally seeded the
* cache) can recognise and overwrite the stub rather than respect it
* as a real assessment.
*
* The write is best-effort — if it fails the caller still sees the
* deterministic fallback text and the next mount falls back to the
* (still expensive) race. We do not surface the error: the user does
* not care that the cache write missed, only that the page rendered.
*/
export async function persistTimeoutStubAndReturn(input: {
userId: string;
cacheAction: string;
todayKey: string;
locale: string;
providerType: string;
stubText: string;
}): Promise<{
hasProvider: true;
text: string;
cached: true;
updatedAt: string | null;
}> {
const { userId, cacheAction, todayKey, locale, providerType, stubText } =
input;
let stubUpdatedAt: string | null = null;
try {
const stub = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: stubText,
providerType,
model: "timeout-stub",
tokensUsed: null,
timeout: true,
}),
},
select: { createdAt: true },
});
stubUpdatedAt = stub.createdAt.toISOString();
} catch {
// The persist is best-effort — silently swallow so the user-facing
// payload still renders even when the row write missed.
}
return {
hasProvider: true,
text: stubText,
cached: true,
updatedAt: stubUpdatedAt,
};
}
+88 -170
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -14,60 +13,26 @@ import {
} from "@/lib/analytics/pulse-targets";
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
export async function generatePulseStatusForUser(
userId: string,
options?: {
@@ -85,13 +50,18 @@ export async function generatePulseStatusForUser(
const cacheAction = `insights.pulse-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -103,35 +73,6 @@ export async function generatePulseStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
// pulling tens of thousands of rows for Apple-Health-rich accounts
@@ -152,13 +93,18 @@ export async function generatePulseStatusForUser(
const now = new Date();
const pulseSeries = applyPayloadBudget(
measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now },
);
const pulsePoints = measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
}));
// `applyPayloadBudget` daily buckets still drive the derived stats
// below (latest, in-target %, delta). They are NOT embedded in the
// prompt — the compact graded series replaces the full daily array.
const pulseSeries = applyPayloadBudget(pulsePoints, { now });
// Primary metric: recent / weekly fold from the bounded raw read, the
// monthly / yearly tail comes from the MONTH / YEAR rollup tier (with
// a full-history in-memory fallback on a cold-tier coverage miss).
const pulseGraded = await buildGradedSeriesWithRollups(userId, "PULSE", now);
const pulseSummary = summarizeSeries(
pulseSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -175,13 +121,12 @@ export async function generatePulseStatusForUser(
})
.then((rows) => rows.reverse());
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
})),
{ now },
);
const moodPoints = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodPoints, { now });
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -245,7 +190,7 @@ export async function generatePulseStatusForUser(
},
pulse: {
summary: pulseSummary,
series: pulseSeries,
series: pulseGraded,
latestDayFocus: latestPulse
? {
dayOffset: latestPulse.dayOffset,
@@ -270,16 +215,22 @@ export async function generatePulseStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
}
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -293,90 +244,57 @@ export async function generatePulseStatusForUser(
locale,
);
// v1.4.28 FB-D2 — cap the provider round-trip at 20 s. On timeout
// (or upstream failure) return the no-key fallback text in a
// cached-style envelope so the status card renders deterministically
// instead of spinning behind React-Query's default retry ladder.
// v1.4.41 — the timeout branch now persists a sentinel keyed to
// today (see `persistTimeoutStubAndReturn`) so subsequent mounts
// short-circuit at the cache lookup instead of re-racing the
// upstream call. The daily pre-warm job overwrites the stub.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyPulseStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for pulse-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Pulse-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+94
View File
@@ -0,0 +1,94 @@
import { prisma } from "@/lib/db";
/**
* Shared cache-read for the seven `*-status.ts` insight generators.
*
* Every generator persists its assessment as an `auditLog` row keyed
* `insights.<metric>-status.<locale>` whose `details` JSON carries
* `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the
* next mount the generator reads the most recent such row and, if it is
* still for today, serves it without re-hitting the provider.
*
* The timeout fallback used to poison this read. When a provider call
* exceeded the status budget the route persisted a `model:"timeout-stub"`
* / `timeout:true` row carrying the generic no-key text under the SAME
* `text` field a real assessment uses. The cache-read only checked
* `dateKey === today && text` — so the stub looked like a valid
* assessment and stuck until midnight, hiding the real data-driven text
* for the rest of the day.
*
* `readFreshStatusText` is the one cache-read every standard generator
* shares. It rejects stubs explicitly so a single stall no longer pins
* the fallback for the day, and a fresh generation is attempted instead.
*/
interface ParsedStatusCache {
dateKey?: string;
locale?: string;
text?: string;
summary?: string;
providerType?: string;
model?: string;
tokensUsed?: number | null;
timeout?: boolean;
}
/**
* A cached row is a timeout stub when it carries the sentinel marker the
* timeout path writes. Either flag is sufficient — older stub rows may
* predate one of the two markers, so both are honoured.
*/
export function isTimeoutStub(parsed: {
model?: string;
timeout?: boolean;
}): boolean {
return parsed.model === "timeout-stub" || parsed.timeout === true;
}
export interface FreshStatusCacheHit {
text: string;
updatedAt: string;
}
/**
* Read the latest cached assessment for `(userId, cacheAction)` and
* return its text only when it is (a) for today and (b) NOT a timeout
* stub. Returns `null` on a miss, a stale day, a stub, or a malformed
* payload — every one of those means the caller should regenerate.
*
* `force` short-circuits to `null` so a forced regeneration never reads
* the cache. The DB read is still skipped entirely under `force` to
* keep the forced path cheap.
*/
export async function readFreshStatusText(args: {
userId: string;
cacheAction: string;
todayKey: string;
force: boolean;
}): Promise<FreshStatusCacheHit | null> {
const { userId, cacheAction, todayKey, force } = args;
if (force) return null;
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!latestCache?.details) return null;
try {
const parsed = JSON.parse(latestCache.details) as ParsedStatusCache;
if (parsed.dateKey !== todayKey) return null;
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
return {
text: parsed.text,
updatedAt: latestCache.createdAt.toISOString(),
};
} catch {
// Malformed cache payload — treat as a miss and regenerate.
return null;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
type ProviderChainResolved,
} from "@/lib/ai/provider-runner";
import { annotate } from "@/lib/logging/context";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
* Shared provider plumbing for the seven `*-status.ts` generators.
*
* Before this, each generator resolved a single provider via
* `resolveProvider()` and raced one `generateCompletion()` call against
* a 20 s cap. A degraded primary provider could not cascade, and the
* cap fired below the providers' own 60 s floor — converting healthy-
* but-slow generations into the generic fallback.
*
* This helper mirrors the Coach (`chat/route.ts`): resolve the user's
* provider chain, fall back to the legacy single provider when the chain
* is empty, and run `runRawCompletionWithFallback` so a degraded provider
* cascades to the next. The whole thing is still wrapped in
* `withTimeout` at the aligned 60 s budget so a total stall can't pin the
* card — but a timeout / error is reported as a transient miss (the
* caller serves the fallback for this render without persisting it),
* never as the day's cached assessment.
*/
export type StatusProviderResult =
| { kind: "none" }
| { kind: "timeout" }
| { kind: "error" }
| {
kind: "ok";
content: string;
providerType: string;
model: string;
tokensUsed: number | null;
};
interface RunStatusCompletionArgs {
userId: string;
cacheAction: string;
systemPrompt: string;
userPrompt: string;
temperature?: number;
maxTokens?: number;
}
/**
* Resolve the provider chain for `userId`, falling back to the legacy
* single provider. Returns `null` when the user has no usable provider
* anywhere (the caller surfaces the no-key fallback with
* `hasProvider:false`).
*/
async function resolveStatusChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Run a status generation across the user's provider chain, bounded by
* `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between
* no-provider / timeout / provider-error / success so the caller can
* decide what to persist — only `ok` is ever cached as the day's
* assessment.
*/
export async function runStatusCompletion(
args: RunStatusCompletionArgs,
): Promise<StatusProviderResult> {
const { userId, cacheAction, systemPrompt, userPrompt } = args;
const chain = await resolveStatusChain(userId);
if (chain === null) {
return { kind: "none" };
}
const raced = await withTimeout(
() =>
runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
temperature: args.temperature ?? 0.3,
maxTokens: args.maxTokens ?? 1000,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut) {
return { kind: "timeout" };
}
if (raced.errored || raced.value === null) {
return { kind: "error" };
}
const { result, workingProvider } = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
annotate({
action: { name: "insights.status.empty_content" },
meta: { cacheAction, providerType: workingProvider.providerType },
});
return { kind: "error" };
}
return {
kind: "ok",
content,
providerType: workingProvider.providerType,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
};
}
export { AllProvidersFailedError };
+137
View File
@@ -0,0 +1,137 @@
/**
* Shared helpers for the seven per-card status generators.
*
* Every `src/lib/insights/*-status.ts` generator carried byte-identical
* copies of `round`, `normalizeSummaryText`, `normalizeLocale`, and (in
* six of seven) `summarizeSeries`, plus an identical "parse the model's
* `{summary}` envelope" block and an identical `prisma.auditLog.create`
* persist block. This module is the single source of truth so a change
* to the rounding precision, the chart-token scrub, or the cache-row
* shape lands once rather than seven times.
*
* The shapes are intentionally narrow — `SupportedLocale` is `de | en`
* because the status prompts ship only those two locales. The
* medication-compliance generator writes a richer cache `details` shape
* (it carries a per-medication array), so it shares `round`,
* `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent`
* but keeps its own `auditLog.create`.
*/
import { prisma } from "@/lib/db";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
export type SupportedLocale = "de" | "en";
/** Round to `digits` decimal places. */
export function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/** Scrub chart tokens and collapse whitespace out of a model summary. */
export function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
/** Narrow an arbitrary locale string to the two the prompts support. */
export function normalizeLocale(
value: string | null | undefined,
): SupportedLocale {
return value === "en" ? "en" : "de";
}
export interface SeriesSummary {
points: number;
start: number;
end: number;
delta: number;
mean: number;
min: number;
max: number;
}
/**
* Fold a value series into a single start/end/delta/mean/min/max
* summary. Returns null for an empty series.
*
* v1.4.33 — fold sum/min/max into a single walk. The previous
* `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
* spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
* path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
* over". These helpers are fed bounded windows today so the crash
* never reached them, but the spread allocates a transient args array
* on every call — the fold is both stack-safe and cheaper.
*/
export function summarizeSeries(
series: Array<{ value: number }>,
): SeriesSummary | null {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Pull the `summary` string out of a model completion. The status
* prompts return a `{ "summary": "…" }` envelope; a model that ignores
* the contract and returns bare prose falls back to the raw content.
*/
export function parseSummaryFromContent(content: string): string {
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") return parsed.summary;
} catch {
// not JSON — fall through to the raw content
}
return content;
}
/**
* Persist one status assessment cache row in the standard text-only
* shape (`{ dateKey, locale, text, providerType, model, tokensUsed }`).
* Returns the row's `createdAt` ISO string. The medication-compliance
* generator writes a richer `details` shape (per-medication array) and
* keeps its own `auditLog.create` call.
*/
export async function persistStatusInsight(args: {
userId: string;
cacheAction: string;
todayKey: string;
locale: SupportedLocale;
text: string;
providerType: string;
model: string;
tokensUsed: number | null;
}): Promise<string> {
const created = await prisma.auditLog.create({
data: {
userId: args.userId,
action: args.cacheAction,
details: JSON.stringify({
dateKey: args.todayKey,
locale: args.locale,
text: args.text,
providerType: args.providerType,
model: args.model,
tokensUsed: args.tokensUsed,
}),
},
select: { createdAt: true },
});
return created.createdAt.toISOString();
}
+80 -75
View File
@@ -5,19 +5,24 @@
* trends + advisor). Each named slug below is a sub-route that focuses
* one metric. Charts on the sub-pages still use the dashboard chart-cog
* surface — see `chartKey` strings in `src/lib/dashboard-layout.ts`
* (`CHART_OVERLAY_KEYS`). The slugs themselves are German because the
* product surface is bilingual and German is the primary author voice.
* (`CHART_OVERLAY_KEYS`).
*
* `medikamente` carries no `MeasurementType[]` because medication
* v1.8.0 — the slugs are English. They were German through v1.7.x; the
* naming-convention ADR (`docs/adr/0001-insights-naming-convention.md`)
* settled the surface: UI copy stays localised (resolved through `t()`
* keys), but internal identifiers — route slugs, tile ids, query keys —
* are English. Legacy `/insights/<de-slug>` URLs 301-redirect to their
* English target via `next.config.ts` `redirects()`.
*
* `medications` carries no `MeasurementType[]` because medication
* compliance is event-driven (`MedicationIntakeEvent` rows) rather than
* time-series `Measurement` rows; the page fetches that data via the
* existing `/api/insights/comprehensive` consumer.
*
* `bmi` lists both `WEIGHT` + `BODY_HEIGHT` because BMI is derived
* client-side — `WEIGHT / (heightCm/100)^2` — and the chart sets
* `valueMode="bmi"` on `<HealthChart>`. `BODY_HEIGHT` is informational
* only (we don't fetch a height series; the user's height lives on the
* profile).
* `bmi` lists `WEIGHT` because BMI is derived client-side —
* `WEIGHT / (heightCm/100)^2` — and the chart sets `valueMode="bmi"` on
* `<HealthChart>`. The user's height lives on the profile, so there is
* no separate height series to fetch.
*/
/**
* Single source of truth for the routed insights sub-pages. The slug
@@ -36,66 +41,66 @@
// so the five pills collapse behind one "Vitalwerte" / "Vitals" parent
// pill with a popover sub-list. The `group: "vitals"` field below
// drives that grouping; the canonical order inside the popover follows
// the strip order. The earlier inline pills (`puls`, `blutdruck`,
// `gewicht`, `bmi`, `schlaf`, `stimmung`, `medikamente`, `workouts`)
// stay flat — they predate v1.4.32 and the maintainer's audit kept
// them as direct entries.
// the strip order. The earlier inline pills (`pulse`, `blood-pressure`,
// `weight`, `bmi`, `sleep`, `mood`, `medications`, `workouts`) stay
// flat — they predate v1.4.32 and the maintainer's audit kept them as
// direct entries.
const SUB_PAGE_METRIC = {
// ── vitals ──
blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
puls: ["PULSE"],
sauerstoff: ["OXYGEN_SATURATION"],
koerpertemperatur: ["BODY_TEMPERATURE"],
"blood-pressure": ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
pulse: ["PULSE"],
oxygen: ["OXYGEN_SATURATION"],
"body-temperature": ["BODY_TEMPERATURE"],
// v1.7.0 — respiratory rate joins the vitals cluster.
atemfrequenz: ["RESPIRATORY_RATE"],
"respiratory-rate": ["RESPIRATORY_RATE"],
// ── body composition ──
gewicht: ["WEIGHT"],
weight: ["WEIGHT"],
// BMI is derived from WEIGHT + profile height (no separate series).
bmi: ["WEIGHT"],
// v1.7.0 — Withings / Apple body-composition tail.
koerperwasser: ["TOTAL_BODY_WATER"],
knochenmasse: ["BONE_MASS"],
"fettfreie-masse": ["FAT_FREE_MASS"],
fettmasse: ["FAT_MASS"],
muskelmasse: ["MUSCLE_MASS"],
viszeralfett: ["VISCERAL_FAT"],
magermasse: ["LEAN_BODY_MASS"],
"body-water": ["TOTAL_BODY_WATER"],
"bone-mass": ["BONE_MASS"],
"fat-free-mass": ["FAT_FREE_MASS"],
"fat-mass": ["FAT_MASS"],
"muscle-mass": ["MUSCLE_MASS"],
"visceral-fat": ["VISCERAL_FAT"],
"lean-body-mass": ["LEAN_BODY_MASS"],
// ── activity ──
"aktive-energie": ["ACTIVE_ENERGY_BURNED"],
"active-energy": ["ACTIVE_ENERGY_BURNED"],
// v1.4.32 — workouts surface; the page reads `Workout` rows directly
// through `useWorkouts()` rather than the `summaries` map, so the
// metric list stays empty.
workouts: [],
// v1.7.0 — activity + Apple-Health Mobility cluster.
stockwerke: ["FLIGHTS_CLIMBED"],
gehstrecke: ["WALKING_RUNNING_DISTANCE"],
gangstabilitaet: ["WALKING_STEADINESS"],
gehpuls: ["WALKING_HEART_RATE_AVERAGE"],
gangasymmetrie: ["WALKING_ASYMMETRY"],
doppelstandphase: ["WALKING_DOUBLE_SUPPORT"],
schrittlaenge: ["WALKING_STEP_LENGTH"],
gehgeschwindigkeit: ["WALKING_SPEED"],
"flights-climbed": ["FLIGHTS_CLIMBED"],
"walking-distance": ["WALKING_RUNNING_DISTANCE"],
"walking-steadiness": ["WALKING_STEADINESS"],
"walking-heart-rate": ["WALKING_HEART_RATE_AVERAGE"],
"walking-asymmetry": ["WALKING_ASYMMETRY"],
"double-support-time": ["WALKING_DOUBLE_SUPPORT"],
"step-length": ["WALKING_STEP_LENGTH"],
"walking-speed": ["WALKING_SPEED"],
// ── sleep ──
schlaf: ["SLEEP_DURATION"],
sleep: ["SLEEP_DURATION"],
// ── cardiovascular ──
ruhepuls: ["RESTING_HEART_RATE"],
"resting-pulse": ["RESTING_HEART_RATE"],
hrv: ["HEART_RATE_VARIABILITY"],
// v1.7.0 — Withings cardiovascular risk markers.
pulswellengeschwindigkeit: ["PULSE_WAVE_VELOCITY"],
gefaessalter: ["VASCULAR_AGE"],
"pulse-wave-velocity": ["PULSE_WAVE_VELOCITY"],
"vascular-age": ["VASCULAR_AGE"],
// ── hearing (v1.7.0) — audio-exposure cluster ──
laermbelastung: ["AUDIO_EXPOSURE_ENV"],
kopfhoererpegel: ["AUDIO_EXPOSURE_HEADPHONE"],
laermereignisse: ["AUDIO_EXPOSURE_EVENT"],
"environmental-audio": ["AUDIO_EXPOSURE_ENV"],
"headphone-audio": ["AUDIO_EXPOSURE_HEADPHONE"],
"audio-events": ["AUDIO_EXPOSURE_EVENT"],
// ── environment (v1.7.0) ──
tageslicht: ["TIME_IN_DAYLIGHT"],
daylight: ["TIME_IN_DAYLIGHT"],
// ── metabolic (v1.7.0) ──
blutzucker: ["BLOOD_GLUCOSE"],
hauttemperatur: ["SKIN_TEMPERATURE"],
"blood-glucose": ["BLOOD_GLUCOSE"],
"skin-temperature": ["SKIN_TEMPERATURE"],
// ── mood ──
stimmung: ["MOOD"],
mood: ["MOOD"],
// ── events (medication adherence is event-driven; no measurement series) ──
medikamente: [],
medications: [],
} as const satisfies Record<string, readonly string[]>;
export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
@@ -112,7 +117,7 @@ export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[];
*/
// v1.7.0 — the new metric clusters each collapse behind a parent pill
// so the strip stays scannable as the sub-page count grows. The flat
// pills (puls, blutdruck, gewicht, bmi, schlaf, stimmung, medikamente,
// pills (pulse, blood-pressure, weight, bmi, sleep, mood, medications,
// workouts) stay direct entries as before.
export type SubPageGroup =
| "vitals"
@@ -126,40 +131,40 @@ export type SubPageGroup =
export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
// vitals
hrv: "vitals",
ruhepuls: "vitals",
sauerstoff: "vitals",
koerpertemperatur: "vitals",
"aktive-energie": "vitals",
atemfrequenz: "vitals",
"resting-pulse": "vitals",
oxygen: "vitals",
"body-temperature": "vitals",
"active-energy": "vitals",
"respiratory-rate": "vitals",
// body composition (v1.7.0)
koerperwasser: "body",
knochenmasse: "body",
"fettfreie-masse": "body",
fettmasse: "body",
muskelmasse: "body",
viszeralfett: "body",
magermasse: "body",
"body-water": "body",
"bone-mass": "body",
"fat-free-mass": "body",
"fat-mass": "body",
"muscle-mass": "body",
"visceral-fat": "body",
"lean-body-mass": "body",
// activity / mobility (v1.7.0)
stockwerke: "activity",
gehstrecke: "activity",
gangstabilitaet: "activity",
gehpuls: "activity",
gangasymmetrie: "activity",
doppelstandphase: "activity",
schrittlaenge: "activity",
gehgeschwindigkeit: "activity",
"flights-climbed": "activity",
"walking-distance": "activity",
"walking-steadiness": "activity",
"walking-heart-rate": "activity",
"walking-asymmetry": "activity",
"double-support-time": "activity",
"step-length": "activity",
"walking-speed": "activity",
// cardiovascular (v1.7.0)
pulswellengeschwindigkeit: "cardiovascular",
gefaessalter: "cardiovascular",
"pulse-wave-velocity": "cardiovascular",
"vascular-age": "cardiovascular",
// hearing (v1.7.0)
laermbelastung: "hearing",
kopfhoererpegel: "hearing",
laermereignisse: "hearing",
"environmental-audio": "hearing",
"headphone-audio": "hearing",
"audio-events": "hearing",
// environment (v1.7.0)
tageslicht: "environment",
daylight: "environment",
// metabolic (v1.7.0)
blutzucker: "metabolic",
hauttemperatur: "metabolic",
"blood-glucose": "metabolic",
"skin-temperature": "metabolic",
};
/**
+46
View File
@@ -0,0 +1,46 @@
import { annotate } from "@/lib/logging/context";
/**
* Shared timeout / provider-error fallback for the InsightStatus routes.
*
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
* (or rejects outright) the route still has to return a deterministic
* envelope so the InsightStatusCard renders the no-key fallback text
* instead of spinning. It used to ALSO persist a sentinel `auditLog`
* row keyed to today's Berlin day. That stub carried the generic
* fallback under the same `text` field a real assessment uses, the
* cache-read served it as valid, and it stuck until midnight — hiding
* the real, data-driven assessment for the rest of the day on any
* account that hit a single transient stall.
*
* The fix removes the persist entirely: a timeout / error is treated as
* a transient miss. The current render still gets the fallback text, but
* nothing is written, so the next mount re-attempts a real generation.
* The shared cache-read (`status-cache.ts`) additionally rejects any
* legacy stub rows still in the table by their `model:"timeout-stub"` /
* `timeout:true` markers.
*
* `cached:true` is kept so the UI does not mislabel the fallback as a
* fresh assessment, and `updatedAt:null` signals "no persisted row".
*/
export function returnTimeoutFallback(input: {
cacheAction: string;
reason: "timeout" | "error";
stubText: string;
}): {
hasProvider: true;
text: string;
cached: true;
updatedAt: null;
} {
annotate({
action: { name: "insights.status.fallback_served" },
meta: { cacheAction: input.cacheAction, reason: input.reason },
});
return {
hasProvider: true,
text: input.stubText,
cached: true,
updatedAt: null,
};
}
+115 -156
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
@@ -18,59 +17,32 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Cap on the embedded correlation pair arrays. The Pearson coefficient
* is still computed over the full overlap; only the row-by-row pair
* list the prompt carries is trimmed to its most recent entries.
*/
const CORRELATION_PAIR_CAP = 30;
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
@@ -121,43 +93,22 @@ export async function generateWeightStatusForUser(
const cacheAction = `insights.weight-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
// Serve today's real assessment when present. The shared reader
// rejects timeout stubs, so a single stall no longer pins the
// fallback for the day.
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap the snapshot input. BP captures three types
@@ -236,6 +187,28 @@ export async function generateWeightStatusForUser(
);
const moodMean = moodSummary?.mean ?? null;
// Compact graded series for the prompt. The `*Series.daily` buckets
// above stay for the correlation pairing + latest-day focus; only the
// graded shape is embedded so the full daily arrays never ship.
//
// Weight + the two BP context channels source their recent / weekly
// slices from a bounded raw read and their monthly / yearly tail from
// the MONTH / YEAR rollup tier (with a full-history in-memory fallback
// on a cold-tier coverage miss). Mood has no rollup tier, so it stays
// an in-memory fold.
const [weightGraded, sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "WEIGHT", now),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
]);
const moodGraded = buildGradedSeriesFromPoints(
moodSeries.daily.map((b) => ({
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
value: b.value,
})),
now,
);
const weightVsSystolicPairs = pairDailyBuckets(
weightSeries.daily,
sysSeries.daily,
@@ -320,7 +293,7 @@ export async function generateWeightStatusForUser(
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: weightSeries,
series: weightGraded,
latestDayFocus: latestWeight
? {
day: latestWeightDay,
@@ -339,19 +312,22 @@ export async function generateWeightStatusForUser(
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: sysSeries,
series: sysGraded,
},
diastolic: {
summary: summarizeSeries(
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: diaSeries,
series: diaGraded,
},
pairedDaily: pairedSystolicDiastolic,
// Keep the correlation coefficients (computed over the full
// overlap) but cap the embedded pair arrays to the most recent
// paired days — the raw pair lists were a major payload driver.
pairedDaily: pairedSystolicDiastolic.slice(-CORRELATION_PAIR_CAP),
},
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs.map((entry) => ({
pairs: weightVsSystolicPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
systolic: round(entry.b, 2),
@@ -359,7 +335,7 @@ export async function generateWeightStatusForUser(
},
weightVsMeanBloodPressure: {
correlation: weightVsMeanBpCorrelation,
pairs: weightVsMeanBpPairs.map((entry) => ({
pairs: weightVsMeanBpPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
meanBloodPressure: round(entry.b, 2),
@@ -371,7 +347,7 @@ export async function generateWeightStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
moodVsWeightCorrelation: (() => {
const moodVsWeightPairs = pairDailyBuckets(
moodSeries.daily,
@@ -384,11 +360,17 @@ export async function generateWeightStatusForUser(
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -402,84 +384,61 @@ export async function generateWeightStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getWeightSystemPrompt(locale),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
// Run the provider chain bounded by the aligned 60 s budget. A
// timeout / error / empty content is a transient miss — serve the
// fallback for this render without persisting it, so the next mount
// re-attempts a real generation rather than sticking the fallback.
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getWeightSystemPrompt(locale),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyWeightStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for weight-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Weight-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+43 -25
View File
@@ -1,25 +1,30 @@
import { annotate } from "@/lib/logging/context";
/**
* v1.4.28 R3a FB-D2 — race an awaited promise against a hard timeout.
* Race an awaited promise against a hard timeout.
*
* The seven `*-status.ts` routes await `provider.generateCompletion()`
* with no client-side timeout. When the upstream provider stalls (cold
* cache, transient brown-out) React-Query's three default retries plus
* default exponential backoff stacks on top of the upstream latency
* and the InsightStatusCard spins for up to 90 s before the user sees
* either the cached fallback text or a generic failure.
* and need an upper bound so a stalled provider does not pin the
* InsightStatusCard behind React-Query's retries. `withTimeout` resolves
* with the wrapped promise's value when it lands inside the budget, or
* the `fallback` value otherwise.
*
* `withTimeout(promise, ms, fallback)` resolves with the wrapped
* promise's value when it lands inside the budget, or the `fallback`
* value otherwise. Use the `signal` payload to abort upstream work so
* the original promise does not keep consuming a provider slot after
* the caller has moved on.
* Returns a typed envelope so the call site can distinguish a real
* timeout from an upstream error from a normal value, and react
* differently to each — a timeout/error must NOT be persisted as a
* day-long cache entry the way a real assessment is.
*
* Returns a typed envelope so the call site can distinguish "timed
* out" from "the upstream returned this value" without re-encoding
* the fallback as a magic string.
* The budget matches the provider clients' own 60 s ceiling. The
* earlier 20 s cap fired below the providers' floor on cold starts and
* model warm-ups, converting healthy-but-slow generations into the
* generic fallback; aligning the two lets a slow-but-valid round-trip
* land instead of being discarded.
*/
export interface TimeoutEnvelope<T> {
/** True when the budget expired before the upstream settled. */
timedOut: boolean;
/** True when the upstream rejected before the budget expired. */
errored: boolean;
value: T;
}
@@ -35,7 +40,11 @@ export function withTimeout<T>(
timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve({ timedOut: true, value: fallback });
annotate({
action: { name: "insights.status.provider_timeout" },
meta: { timeout_ms: ms },
});
resolve({ timedOut: true, errored: false, value: fallback });
}, ms);
start()
@@ -46,26 +55,35 @@ export function withTimeout<T>(
clearTimeout(timer);
timer = null;
}
resolve({ timedOut: false, value });
resolve({ timedOut: false, errored: false, value });
})
.catch(() => {
.catch((error: unknown) => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
// Upstream errored before the timeout fired — surface the
// fallback so the route still returns a deterministic shape
// and the caller does not have to learn another failure mode.
resolve({ timedOut: true, value: fallback });
// Upstream errored before the timeout fired. Surface it as a
// distinct `errored` envelope and annotate the reason so a real
// provider failure is observable in the wide events instead of
// being silently swallowed into the timeout path.
const reason =
error instanceof Error ? error.message : String(error ?? "unknown");
annotate({
action: { name: "insights.status.provider_error" },
meta: { reason: reason.slice(0, 240) },
});
resolve({ timedOut: false, errored: true, value: fallback });
});
});
}
/**
* The 20-second cap shipped in v1.4.28. Provider round-trips that
* exceed this budget are converted to a cached-fallback render with a
* "couldn't reach provider" caption on the InsightStatusCard.
* Status-path provider budget, aligned with the provider clients' own
* 60 s ceiling. A round-trip that exceeds this is treated as a transient
* miss — the caller returns the deterministic fallback for this render
* WITHOUT persisting it as the day's assessment, so the next mount
* re-attempts a real generation.
*/
export const STATUS_PROVIDER_TIMEOUT_MS = 20_000;
export const STATUS_PROVIDER_TIMEOUT_MS = 60_000;
@@ -27,6 +27,30 @@ vi.mock("@/lib/feature-flags", () => ({
vi.mock("@/lib/insights/comprehensive-generate", () => ({
generateComprehensiveInsight: vi.fn(),
}));
// The seven status generators import the provider chain transitively;
// stub the modules so the warm-pass never touches a live provider when
// the test does not inject its own generators.
vi.mock("@/lib/insights/blood-pressure-status", () => ({
generateBloodPressureStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/pulse-status", () => ({
generatePulseStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/weight-status", () => ({
generateWeightStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/bmi-status", () => ({
generateBmiStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/mood-status", () => ({
generateMoodStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/medication-compliance-status", () => ({
generateMedicationComplianceStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/general-status", () => ({
generateGeneralStatusForUser: vi.fn(),
}));
import {
runInsightPregenerate,
@@ -180,6 +204,100 @@ describe("runInsightPregenerate — outcome tally", () => {
});
});
describe("runInsightPregenerate — per-metric warm pass", () => {
function warmGen(result: { hasProvider: boolean; cached: boolean }) {
return vi.fn().mockResolvedValue(result);
}
it("forces all seven status generators after a successful comprehensive generation", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: true, cached: false }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
// Every status generator forced with the resolved locale.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true });
}
// Seven fresh, provider-backed assessments warmed.
expect(result.assessmentsWarmed).toBe(7);
});
it("does NOT warm when the comprehensive pass skipped (no provider)", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "en" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no-provider" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: false, cached: true }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
for (const g of statusGenerators) {
expect(g).not.toHaveBeenCalled();
}
expect(result.assessmentsWarmed).toBe(0);
});
it("does NOT warm when the comprehensive pass failed", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "failed", reason: "provider-error" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: true, cached: false }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
for (const g of statusGenerators) {
expect(g).not.toHaveBeenCalled();
}
expect(result.assessmentsWarmed).toBe(0);
});
it("counts only fresh provider-backed assessments and survives a thrown generator", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = [
warmGen({ hasProvider: true, cached: false }), // counts
warmGen({ hasProvider: false, cached: true }), // no provider — skip
warmGen({ hasProvider: true, cached: true }), // served cache — skip
vi.fn().mockRejectedValue(new Error("boom")), // throws — swallowed
warmGen({ hasProvider: true, cached: false }), // counts
];
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
// The throw must not abort the loop — every generator was attempted.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
}
expect(result.assessmentsWarmed).toBe(2);
});
});
describe("queue registration", () => {
const workerSrc = fs.readFileSync(
path.resolve(__dirname, "../reminder-worker.ts"),
+140 -1
View File
@@ -10,6 +10,16 @@
* snapshot embeds a `briefingState: "ready"` block instead of
* `"preparing"`.
*
* v1.8.0 — warm the per-metric assessment caches too. The comprehensive
* generator evicts the seven `insights.<scope>-status.<locale>` caches
* as part of its write (so a stale comprehensive insight can never leave
* a stale per-metric card behind) but does NOT re-fill them. Before this,
* the morning after the cron ran the per-metric status caches were
* empty, so the first `/insights` mount still ran one live status
* generation per card. After a successful comprehensive generation this
* cron now forces the seven `*StatusForUser` generators so their caches
* are warm, not empty, by the user's first visit.
*
* Runs once nightly at 04:30 Europe/Berlin — inside the existing
* 03:xx–04:xx maintenance window, after the feedback aggregator (04:00)
* and the cumulative drain (03:30) so the late-night CPU profile stays
@@ -35,13 +45,23 @@
* Recurring pg-boss task — never runs inside an HTTP request and never
* shells out to `tsx` (CLAUDE.md DO-NOTs).
*/
import pLimit from "p-limit";
import type { PrismaClient } from "@/generated/prisma/client";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import {
generateComprehensiveInsight,
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate";
@@ -63,6 +83,17 @@ export const PREGENERATE_BATCH_CAP = 200;
/** Per-user budget bucket window — one pre-generation per 20 h. */
const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000;
/**
* Concurrency for the per-user warm pass. The seven `*StatusForUser`
* generators are independent (each writes its own cache row), so they run
* a few at a time rather than strictly serially — a daily-weigher with
* every card configured was paying ~7× one LLM round-trip per user back
* to back. Capped low (3) so the warm pass cannot saturate the Prisma
* pool or fan out an unbounded number of concurrent provider calls; the
* per-user budget gate above still bounds total cost per night.
*/
const WARM_PASS_CONCURRENCY = 3;
export interface InsightPregeneratePayload {
triggeredAt: string;
}
@@ -74,6 +105,76 @@ export interface PregenerateRunResult {
skipped: number;
failed: number;
budgetBlocked: number;
/**
* Count of per-metric assessment caches written warm across the whole
* run. The comprehensive generator evicts the seven `*-status`
* caches as part of its write (so a stale comprehensive insight can't
* leave a stale per-metric card), but it does not re-fill them — so
* without this warm pass the first morning visit after the cron runs
* one live status generation per card. This counts the warm writes.
*/
assessmentsWarmed: number;
}
/**
* Signature for one per-metric status generator. The seven
* `*StatusForUser` functions all share this shape: `(userId, { locale,
* force }) => { hasProvider, text, cached, updatedAt }`. We only need to
* know whether the call produced a fresh (non-cached, provider-backed)
* assessment, so the warm-pass narrows to those two fields.
*/
type StatusGenerator = (
userId: string,
options: { locale: "de" | "en"; force?: boolean },
) => Promise<{ hasProvider: boolean; cached: boolean }>;
/**
* The seven per-metric status generators the warm pass re-fills after
* the comprehensive generator evicted them. Each writes its own
* `insights.<scope>-status.<locale>` cache row on a fresh generation and
* resolves the user's provider chain internally, so a provider-less
* account costs one cheap chain-resolve and no LLM call (`hasProvider:
* false`).
*/
const DEFAULT_STATUS_GENERATORS: ReadonlyArray<StatusGenerator> = [
generateBloodPressureStatusForUser,
generatePulseStatusForUser,
generateWeightStatusForUser,
generateBmiStatusForUser,
generateMoodStatusForUser,
generateMedicationComplianceStatusForUser,
generateGeneralStatusForUser,
];
/**
* Re-fill the per-metric assessment caches for one user, forcing a fresh
* generation so the row the comprehensive generator just evicted is
* written warm. Returns the count of fresh (provider-backed,
* non-cached) assessments written. A generator that has no provider or
* fails serves its fallback without persisting it, so it does not count
* toward the warmed tally and never throws the batch loop off course.
*/
async function warmPerStatusCaches(
userId: string,
locale: "de" | "en",
generators: ReadonlyArray<StatusGenerator>,
): Promise<number> {
const limit = pLimit(WARM_PASS_CONCURRENCY);
const results = await Promise.all(
generators.map((generate) =>
limit(async () => {
try {
const outcome = await generate(userId, { locale, force: true });
return outcome.hasProvider && !outcome.cached ? 1 : 0;
} catch {
// A single card's generation failing must not abort the rest of
// the warm pass for this user, nor the cron's user loop.
return 0;
}
}),
),
);
return results.reduce((sum: number, n) => sum + n, 0);
}
interface PregenerateCandidate {
@@ -130,11 +231,15 @@ export async function runInsightPregenerate(
userId: string,
opts: { locale: "de" | "en"; force?: boolean },
) => Promise<GenerateOutcome>;
/** Injected for the test — defaults to the seven real status generators. */
statusGenerators?: ReadonlyArray<StatusGenerator>;
} = {},
): Promise<PregenerateRunResult> {
const now = options.now ?? new Date();
const cap = options.cap ?? PREGENERATE_BATCH_CAP;
const generate = options.generate ?? generateComprehensiveInsight;
const statusGenerators =
options.statusGenerators ?? DEFAULT_STATUS_GENERATORS;
const result: PregenerateRunResult = {
total: 0,
@@ -143,6 +248,7 @@ export async function runInsightPregenerate(
skipped: 0,
failed: 0,
budgetBlocked: 0,
assessmentsWarmed: 0,
};
// Master kill-switch — when the operator disabled the briefing
@@ -174,8 +280,9 @@ export async function runInsightPregenerate(
// pre-generation — defeating the "warm the cache before the user's
// morning visit" intent for the common case. The per-user budget
// bucket (above) is what bounds cost, not the TTL re-check.
const locale = normalizeLocale(candidate.locale);
const outcome = await generate(candidate.id, {
locale: normalizeLocale(candidate.locale),
locale,
force: true,
});
switch (outcome.status) {
@@ -192,7 +299,39 @@ export async function runInsightPregenerate(
result.failed++;
break;
}
// Warm the per-metric assessment caches the comprehensive generator
// evicts (`evictPerStatusInsightCache`) but does not re-fill. Only
// do this when the comprehensive pass actually produced an insight:
// a `skipped` (no-provider) user has nothing to warm, and a `failed`
// pass means the provider chain is unhealthy this run — re-running
// it seven more times would only multiply the failure. A `cached`
// outcome can't happen here (we always force), but if a future
// change reintroduces it the per-metric caches were not evicted, so
// there is nothing to warm.
if (outcome.status === "generated") {
result.assessmentsWarmed += await warmPerStatusCaches(
candidate.id,
locale,
statusGenerators,
);
}
}
// Wide-event tally so the nightly dashboard can track how many
// comprehensive insights and per-metric assessments each tick wrote.
annotate({
action: { name: "insights.pregenerate.run" },
meta: {
total: result.total,
generated: result.generated,
cached: result.cached,
skipped: result.skipped,
failed: result.failed,
budget_blocked: result.budgetBlocked,
assessments_warmed: result.assessmentsWarmed,
},
});
return result;
}
+16 -4
View File
@@ -44,7 +44,7 @@ import {
MEDICATION_TREATMENT_CLASS_VALUES,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
/**
@@ -1121,14 +1121,26 @@ medicationExtractionSchema.meta({
// Insights tile layout — mirrors the Zod schema in
// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived
// from the same `INSIGHTS_TILE_IDS` source so the contract cannot drift.
// from the same `ACCEPTED_INSIGHTS_TILE_IDS` source so the contract
// cannot drift.
//
// v1.8.0 — the canonical ids are English (`blood-pressure`, `pulse`,
// `oxygen`, `body-temperature`, `weight`, `active-energy`, `sleep`,
// `resting-pulse`, `mood`, `medications`). The endpoint still ACCEPTS
// the legacy German ids (`blutdruck`, `puls`, `sauerstoff`,
// `koerpertemperatur`, `gewicht`, `aktive-energie`, `schlaf`,
// `ruhepuls`, `stimmung`, `medikamente`) on input so existing iOS
// layouts keep validating; the server normalises them to the canonical
// English id before persisting, and GET always returns canonical ids.
// The legacy ids are accepted-but-deprecated and will be dropped from
// the accepted set in a future major.
const insightsLayoutSchema = z
.object({
version: z.literal(1),
tiles: z
.array(
z.object({
id: z.enum(INSIGHTS_TILE_IDS),
id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS),
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
@@ -1139,7 +1151,7 @@ const insightsLayoutSchema = z
.meta({
id: "InsightsLayoutBody",
description:
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version; tile ids are a closed enum derived from the server's tile registry.",
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. The legacy ids are deprecated and will be removed in a future major version.",
});
// v1.7.0 — health-record export selection. Strict shape: unknown keys
+12
View File
@@ -43,6 +43,7 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { hasActivityScope } from "./client";
import {
@@ -371,6 +372,17 @@ export async function syncUserActivity(
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (steps feed the general overview). Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings activity: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings activity: rollup recompute failed for ${userId}: ${err}`,
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more