mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
6ad0a8a5cf
commit
d5eb887757
19 files changed
+564
-78
No files matched your search
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user