feat(insights): adopt the canonical per-metric detail template and terminology

Bring the web per-metric Insights detail pages to structural parity with
the canonical metric template: a single top-to-bottom spine, every block
self-suppressing when its data is absent.

New spine (above the chart): header (title + target gear + Coach circle)
→ intro → primary tile → Letzte Messung → stat strip; then the chart,
the range controls + period delta, the per-metric correlation card, and
the Einschätzung assessment last.

- Add the primary tile (headline value + 30-day average + an In-range
  bar shown only when the metric carries a per-metric in-target pct).
  Blood pressure keeps its richer target panel as its primary tile.
- Add the Letzte Messung card (labeled heading + last-reading date),
  reading the last-seen timestamp the slim analytics slice already
  carries — no extra fetch.
- Relocate the owning correlation onto its metric page: weight × weekday
  on Weight, mood × pulse on Pulse. The card reads the correlations
  already on the analytics payload and self-gates below the surfacing
  bar; it is now the canonical home for those relationships.
- Move the headline value, 30-day average and in-target share out of the
  target reference panel so each figure appears in exactly one place;
  the panel stays the band reference (range, status, positional bar,
  consistency strip, source). Blood pressure keeps its stitched S/D
  30-day average inline.
- Order the Einschätzung last on every page.

Terminology: adopt the canonical labels — rename the mood patterns
surface to "Was auffällt", pluralise the deviation-count card to
"Signale des Tages", and align the settings section labels to
"Über diese App" and "Quellen-Priorität". New In-range /
30-day-average / Letzte Messung strings land in all six locales.

Web charts are unchanged — Recharts and the existing chart tokens
stay; the iOS monochrome chart doctrine is not applied.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 23:03:05 +02:00
1 parent 6ad0a8a5cf
commit d5eb887757
19 files changed
+564 -78

No files matched your search

+16 -3
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -46,7 +47,10 @@ export default function InsightsBlutdruckPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("blood-pressure");
const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const { data: analytics, isEmpty } =
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const bpLastSeenAt =
analytics?.lastSeenByType?.BLOOD_PRESSURE_SYS?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -103,6 +107,15 @@ export default function InsightsBlutdruckPage() {
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
coachLaunch
primary={
<>
{/* Blood pressure's primary tile IS its richer target panel
(ESH classification + sys/dia band + 30-day S/D average),
promoted above the chart per the canonical spine. */}
<MetricTargetSummary slug="blood-pressure" />
<MetricLastMeasurementCard lastSeenAt={bpLastSeenAt} />
</>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="BLOOD_PRESSURE_SYS"
@@ -132,8 +145,8 @@ export default function InsightsBlutdruckPage() {
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
<MetricTargetSummary slug="blood-pressure" />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
+27 -15
View File
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -52,6 +55,8 @@ export default function InsightsPulsPage() {
// the same key).
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
const pulseLastSeenAt = analytics?.lastSeenByType?.PULSE?.lastSeenAt ?? null;
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
@@ -116,12 +121,13 @@ export default function InsightsPulsPage() {
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
statStrip={
<MetricStatStrip
summary={analytics?.summaries?.PULSE ?? null}
unit="bpm"
/>
primary={
<>
<MetricPrimaryTile summary={pulseSummary} unit="bpm" slug="pulse" />
<MetricLastMeasurementCard lastSeenAt={pulseLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
@@ -147,16 +153,6 @@ export default function InsightsPulsPage() {
<MetricTargetSummary slug="pulse" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"). The chart-row stays mounted even at zero
@@ -168,6 +164,22 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
<MetricCorrelationCard slug="pulse" />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
</SubPageShell>
);
}
+6 -1
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
@@ -35,7 +36,10 @@ export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
const { data: analytics, isEmpty } =
useInsightsAnalytics("SLEEP_DURATION");
const sleepLastSeenAt =
analytics?.lastSeenByType?.SLEEP_DURATION?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -67,6 +71,7 @@ export default function InsightsSchlafPage() {
description={t("insights.sleep.description")}
explainerMetric="sleep"
coachLaunch
primary={<MetricLastMeasurementCard lastSeenAt={sleepLastSeenAt} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="SLEEP_DURATION"
+15
View File
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -42,6 +45,8 @@ export default function InsightsGewichtPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
const weightLastSeenAt =
analytics?.lastSeenByType?.WEIGHT?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -79,6 +84,12 @@ export default function InsightsGewichtPage() {
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
primary={
<>
<MetricPrimaryTile summary={weightSummary} unit="kg" slug="weight" />
<MetricLastMeasurementCard lastSeenAt={weightLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
@@ -113,6 +124,10 @@ export default function InsightsGewichtPage() {
compact
/>
{/* v1.12.0 — Weight owns the weight × weekday correlation (relocated
off the overview onto its metric page). */}
<MetricCorrelationCard slug="weight" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
@@ -154,12 +154,15 @@ const GLUCOSE_DATA: TargetsFixture = {
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
it("renders the numeric range 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");
// v1.12.0 — the in-target share moved up to `<MetricPrimaryTile>`,
// the canonical home for the headline + 30-day average + in-range
// bar. The band-reference panel no longer repeats it for non-BP
// metrics.
expect(html).not.toContain("of logged days within target");
});
it("renders the verbal status pill + the guideline source", () => {
@@ -175,21 +178,26 @@ describe("<MetricTargetSummary>", () => {
);
});
it("renders the range bar + the 30-day average + the consistency strip", () => {
it("renders the range bar + the consistency strip", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain('data-slot="consistency-strip"');
expect(html).toContain("30-day average: 71.4 kg");
// v1.12.0 — the 30-day average moved up to `<MetricPrimaryTile>` for
// every metric except blood pressure (whose richer panel IS its
// primary tile and keeps the stitched S/D average inline).
expect(html).not.toContain("30-day average: 71.4 kg");
});
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");
// v1.12.0 — the in-target share moved up to the primary tile; the BP
// panel keeps only the stitched 30-day average inline.
expect(html).not.toContain("of logged days within target");
// The diastolic range bar renders alongside the systolic one.
const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length;
expect(barCount).toBe(2);
// 30-day average stitches the diastolic pair.
// 30-day average stitches the diastolic pair — kept on the BP panel.
expect(html).toContain("30-day average: 122/76 mmHg");
});
@@ -19,6 +19,8 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
@@ -177,6 +179,9 @@ export function HealthKitMetricPage({
}
: rawSummary;
const lastSeenAt =
analytics?.lastSeenByType?.[measurementType]?.lastSeenAt ?? null;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
@@ -211,6 +216,16 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
primary={
<>
<MetricPrimaryTile
summary={summary}
unit={yAxisUnit ?? unit}
slug={targetSummarySlug}
/>
<MetricLastMeasurementCard lastSeenAt={lastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
diversityNudge={
<MeasurementDiversityNudge
@@ -244,13 +259,6 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{statusMetric ? (
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled={!isEmpty}
/>
) : null}
{forecast && isTrajectoryType(measurementType) ? (
<TrajectoryForecastCard
type={measurementType}
@@ -261,6 +269,15 @@ export function HealthKitMetricPage({
compact
/>
) : null}
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
{statusMetric ? (
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled={!isEmpty}
/>
) : null}
</SubPageShell>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import type {
CorrelationKind,
CorrelationResult,
} from "@/lib/insights/correlations";
import { CorrelationCard } from "@/components/insights/correlation-card";
/**
* v1.12.0 — per-metric correlation card.
*
* The canonical home for the one cross-metric relationship a metric
* owns (the iOS canonical template relocates correlations off the
* overview onto the owning metric page):
*
* • Weight page → weight × weekday
* • Pulse page → mood × pulse
*
* It reads the already-computed `correlations` block off the thick
* `/api/analytics` slice — the same payload the Insights overview warms.
* On the common path (user lands on the overview first) this is a free
* cache read; on direct metric-page entry it pays one thick fetch, the
* same trade-off `<MetricTargetSummary>` already makes for the targets
* payload.
*
* Self-suppressing: renders nothing while the payload is in-flight, when
* the correlations feature is off, or when the owned hypothesis is below
* the statistical surfacing bar (`status !== "ok"`). The single
* `<CorrelationCard>` carries its own scatter + interpretation + source.
*/
interface AnalyticsWithCorrelations {
correlations?: Partial<Record<string, CorrelationResult>>;
}
/** Insights slug → the `correlations` payload key it owns. */
const SLUG_TO_CORRELATION: Record<string, string> = {
weight: "weightWeekday",
pulse: "moodPulse",
};
/** The `kind` the owned result must carry — guards a payload-key drift. */
const SLUG_TO_KIND: Record<string, CorrelationKind> = {
weight: "weight-weekday",
pulse: "mood-pulse",
};
interface MetricCorrelationCardProps {
/** Insights category slug, e.g. `"weight"`. */
slug: string;
}
export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
const key = SLUG_TO_CORRELATION[slug];
// Read the thick slice; correlations live only on the default payload.
// The query is gated by the hook on `isAuthenticated`, and disabled
// entirely when this slug owns no correlation so a target-less metric
// never fires the thick fetch.
const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
const result = analytics?.correlations?.[key];
if (!result || result.status !== "ok") return null;
// Guard against a payload-shape drift: only paint when the kind the
// server returned matches the kind this slug claims to own.
if (result.kind !== SLUG_TO_KIND[slug]) return null;
return (
<section
data-slot="metric-correlation"
data-correlation-slug={slug}
aria-label={t("insights.correlationRow.title")}
className="space-y-2"
>
<h2 className="text-lg font-semibold">
{t("insights.correlationRow.title")}
</h2>
<CorrelationCard result={result} />
<p
data-slot="metric-correlation-disclaimer"
className="text-muted-foreground text-xs italic"
>
{t("insights.correlationRow.disclaimer")}
</p>
</section>
);
}
@@ -0,0 +1,65 @@
"use client";
import { Clock } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
/**
* v1.12.0 — canonical "Letzte Messung" card.
*
* The third block on the per-metric detail page (after the primary
* tile), mirroring the iOS `InsightsLastMeasurementCard`. It captions
* WHEN the most recent reading landed — the date plus a "vor X Tagen"
* recency line.
*
* It deliberately does NOT repeat the latest value: the headline value
* lives in `<MetricPrimaryTile>` only (the no-duplicate-info rule). This
* card owns the timing dimension; the primary tile owns the value.
*
* Self-suppressing: renders nothing when no `lastSeenAt` is available
* (a brand-new metric, or a metric whose freshness map has no entry),
* so a sparse page never carries an empty timing card.
*/
interface MetricLastMeasurementCardProps {
/**
* ISO timestamp of the latest reading for this metric, read from the
* analytics `lastSeenByType[type]?.lastSeenAt` slot. Null / undefined
* suppresses the card.
*/
lastSeenAt: string | null | undefined;
}
export function MetricLastMeasurementCard({
lastSeenAt,
}: MetricLastMeasurementCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!lastSeenAt) return null;
const parsed = new Date(lastSeenAt);
if (Number.isNaN(parsed.getTime())) return null;
return (
<section
data-slot="metric-last-measurement"
className="bg-card border-border flex items-center gap-3 rounded-xl border px-4 py-3"
>
<div className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full">
<Clock className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{t("insights.subPage.lastMeasurement.label")}
</p>
<p className="text-sm font-medium">
{fmt.dateWithWeekday(parsed)}
<span className="text-muted-foreground ml-2 font-normal">
{formatRelativeTime(lastSeenAt, t)}
</span>
</p>
</div>
</section>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
/**
* v1.12.0 — canonical metric primary tile.
*
* The first data block on every per-metric detail page, mirroring the
* iOS `InsightsPrimaryTile`:
*
* • the headline value — the latest reading (the single place on the
* page the headline number appears; the stat strip below carries
* min / max / median / mean, not the latest),
* • the 30-Tage-Durchschnitt (`summary.avg30`) as a secondary line,
* • an "Im Zielbereich" bar — the share of logged days in target over
* the last 30, shown ONLY when the metric carries a per-metric pct
* (a registered target band feeds `/api/insights/targets`).
*
* Self-suppressing: renders nothing without a summary or with a
* zero-count series (the page empty-state owns that surface). The
* in-target bar omits itself when the slug has no numeric target or the
* route flagged insufficient data, so a target-less metric (HRV, SpO₂,
* temperature, …) reads as a clean headline + 30-day average.
*
* Blood pressure does NOT use this tile — its richer BP target panel is
* its primary tile (the page omits this component and leads with
* `<MetricTargetSummary slug="blood-pressure" />`).
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
range: TargetRange | null;
}
interface TargetsResponse {
targets: TargetItem[];
}
/**
* Insights slug → target `type`. Mirrors `metric-target-summary.tsx`'s
* map for the single-target metrics. Slugs absent from the map (HRV,
* SpO₂, temperature, …) carry no in-target pct, so the bar suppresses.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
};
interface MetricPrimaryTileProps {
/**
* Analytics summary for the page's `MeasurementType`. Null is allowed
* so the parent doesn't gate on the network read finishing.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to the value (e.g. `bpm`, `kg`). */
unit: string;
/**
* Insights category slug (e.g. `"weight"`). When the slug maps to a
* registered target, the tile reads the in-target pct from
* `/api/insights/targets` and paints the "Im Zielbereich" bar. Omit
* for metrics with no numeric target — the tile then shows headline +
* 30-day average only.
*/
slug?: string;
/** Decimal precision for the formatted values. Defaults to 1. */
fractionDigits?: number;
}
export function MetricPrimaryTile({
summary,
unit,
slug,
fractionDigits = 1,
}: MetricPrimaryTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const targetType = slug ? SLUG_TO_TARGET_TYPE[slug] : undefined;
const { data: targets } = 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,
});
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
if (!summary || summary.count <= 0) return null;
const headline =
summary.latest === null
? "—"
: `${fmt.number(summary.latest, fractionDigits)} ${unit}`;
const avg30 =
summary.avg30 === null
? null
: `${fmt.number(summary.avg30, fractionDigits)} ${unit}`;
// In-target share: fraction of logged days in the green band over the
// last 30, read verbatim from the targets route (never recomputed).
const target = targetType
? targets?.targets.find((entry) => entry.type === targetType)
: undefined;
const showShare =
target != null &&
target.range != null &&
!target.insufficientData &&
target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<section
data-slot="metric-primary-tile"
className="bg-card border-border space-y-3 rounded-xl border p-4"
>
<div className="space-y-0.5">
<p
data-slot="metric-primary-value"
className="text-3xl font-semibold tabular-nums"
>
{headline}
</p>
{avg30 ? (
<p className="text-muted-foreground text-sm">
{t("insights.subPage.primary.avg30Label")}: {avg30}
</p>
) : null}
</div>
{/* "Im Zielbereich" bar — share of the last 30 logged days inside
the target band. Self-suppresses for target-less metrics and on
insufficient data. */}
{sharePct != null ? (
<div data-slot="metric-primary-in-range" className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<p className="text-muted-foreground text-xs">
{t("insights.subPage.primary.inRangeLabel")}
</p>
<p className="text-sm font-medium tabular-nums">{sharePct}%</p>
</div>
<div
className="bg-muted h-2 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("insights.subPage.primary.inRangeAria", {
pct: sharePct,
})}
>
<div
className={cn(
"h-full rounded-full transition-[width]",
sharePct >= 70 ? "bg-dracula-green" : "bg-dracula-orange",
)}
style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }}
/>
</div>
</div>
) : null}
</section>
);
}
@@ -298,20 +298,19 @@ function TargetReferencePanel({
const sourceLink = getTargetSourceLink(target);
// 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 suppresses the share line.
const showShare = !target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
// 30-day average. BP stitches the diastolic average into the familiar
// `S/D` pair; everything else rounds to one decimal.
// v1.12.0 — the in-target share (% of logged days in band) and the
// 30-day average moved UP to `<MetricPrimaryTile>`, the canonical home
// for the headline + 30-day average + "Im Zielbereich" bar (the
// no-duplicate-info rule). This panel stays the band reference: the
// range string, the status pill, the positional range bar (where
// today's value sits inside the band — distinct from the % bar), the
// 7-day consistency strip, and the guideline source. Blood pressure is
// the exception: it has no `<MetricPrimaryTile>` (this richer panel IS
// its primary tile), so it keeps the stitched S/D 30-day average inline.
let averageLabel: string | null = null;
if (target.average30 != null) {
if (isBp && target.average30 != null) {
const avgValue =
isBp && bpDiastolic?.average30 != null
bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: String(Math.round(target.average30 * 10) / 10);
averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`;
@@ -383,15 +382,12 @@ function TargetReferencePanel({
</div>
) : null}
{/* Row 3: in-target share + 30-day average. */}
{sharePct != null || averageLabel ? (
{/* Row 3: 30-day average (blood pressure only — every other metric
surfaces its 30-day average in `<MetricPrimaryTile>` above the
chart, so repeating it here would duplicate the figure). */}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{sharePct != null ? (
<span>
{t("insights.subPage.target.inTargetShare", { pct: sharePct })}
</span>
) : null}
{averageLabel ? <span>{averageLabel}</span> : null}
<span>{averageLabel}</span>
</div>
) : null}
@@ -86,6 +86,15 @@ export interface SubPageShellProps {
* min / max / median / mean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.12.0 — canonical "primary" zone rendered between the header and
* the stat strip, the top of the metric-detail spine: the primary tile
* (headline value + 30-Tage-Durchschnitt + "Im Zielbereich" bar) and
* the "Letzte Messung" card. Each child self-suppresses when its data
* is absent, so a sparse metric reads as a clean heading + chart. The
* mother page and empty-state branches omit it.
*/
primary?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge.
*
@@ -126,6 +135,7 @@ export function SubPageShell({
explainerMetric,
focusOnMount = false,
statStrip,
primary,
diversityNudge,
coachLaunch = false,
showAllValuesType,
@@ -253,6 +263,11 @@ export function SubPageShell({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.12.0 — the canonical "primary" zone leads the spine: the
primary tile (headline + 30-Tage-Durchschnitt + Im-Zielbereich
bar) and the "Letzte Messung" card, both above the stat strip.
Each child self-suppresses when its data is absent. */}
{primary}
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
and the chart so the page leads with data, mirroring the Apple
Health / Withings detail layout. Self-gating (renders nothing
@@ -169,7 +169,9 @@ describe("<SettingsShell>", () => {
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
expect(html).toContain("Sources");
// v1.12.0 — the source-priority entry adopts the canonical
// "Source priority" / "Quellen-Priorität" label (handover §5).
expect(html).toContain("Source priority");
});
it("resolves every section title via the i18n provider — German", () => {
+11
View File
@@ -41,6 +41,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
export interface SubPageAnalyticsData {
/** `Record<MeasurementType, DataSummary>` — sentinel value for "not loaded yet" is `undefined`. */
summaries: Record<string, DataSummary>;
/**
* v1.12.0 — per-type freshness map, also carried on the slim
* `?slice=summaries` branch (`computeSummariesSlice`). The metric
* detail page's "Letzte Messung" card reads
* `lastSeenByType[type]?.lastSeenAt` to caption when the latest
* reading landed, without paying for the thick analytics slice.
*/
lastSeenByType?: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
}
/**