mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge v1.8.0 Insights release into main
# Conflicts: # CHANGELOG.md # docs/api/openapi.yaml # package.json
This commit is contained in:
commit
869f13612a
115 files changed
+5736
-2737
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" />}
|
||||
@@ -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" />}
|
||||
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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="%"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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"
|
||||
@@ -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="%"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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=""
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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" />}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user