mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(insights): add a calm device-score tile for vendor-native metrics
The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS charge, body-temperature deviation, …) is ingested end-to-end and lit in the AI chart-token allowlist, but no page renders it. These scores sit outside the metric-status registry by design — they are vendor-rolled-up values, not signals the app re-derives — so they carry no assessment card. DeviceScoreTile is the surface for them: a title, the latest reading, the trailing-window mean, and a small sparkline, read straight from the stored analytics summaries slice (server-authoritative, never recomputed). One neutral card, never a status colour. When the series is still sparse the sparkline gives way to the shared LearningGate so a two-night-old strap never paints a jagged two-point line. The connected variant reads its own summary so it can drop onto a page that already owns an analytics read. Add the page-level copy in all six locales.
This commit is contained in:
1 parent
d1055b4047
commit
794192c089
7 files changed
+474
-18
No files matched your search
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
|
||||
/**
|
||||
* v1.17.1 — calm, data-gated readout for a device-native score that has no
|
||||
* generic assessment registry entry.
|
||||
*
|
||||
* The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
|
||||
* charge, …) is ingested end-to-end and lit in the AI chart-token allowlist,
|
||||
* but no page renders it. These metrics are deliberately OUTSIDE the
|
||||
* `metric-status-registry` (they're vendor-rolled-up scores, not signals the
|
||||
* app re-derives), so they carry no `<MetricStatusCard>`. This tile is the
|
||||
* surface for them: a title, the latest reading, the trailing-window mean, and
|
||||
* a small sparkline — server-authoritative throughout (it reads the stored
|
||||
* `summaries[type]` slice; it never recomputes).
|
||||
*
|
||||
* Calm posture (per the maintainer's standing card rules): one neutral card,
|
||||
* never an alarming colour, never a green-when-good / red-when-bad tint. The
|
||||
* caller passes the single chart line colour; the card chrome stays neutral.
|
||||
*
|
||||
* Data-gating is the caller's job: mount this tile only when
|
||||
* `summary.count > 0` (the parent section already reads the shared analytics
|
||||
* payload, so this avoids a second fetch and keeps the empty surface clean for
|
||||
* non-wearable users). When the series is present but still sparse
|
||||
* (`count < learningThreshold`), the tile swaps the sparkline for a
|
||||
* `<LearningGate>` so a two-night-old strap never paints a jagged
|
||||
* two-point line that reads as a trend.
|
||||
*/
|
||||
|
||||
export interface DeviceScoreTileProps {
|
||||
/** The MeasurementType backing the readout + sparkline. */
|
||||
type: string;
|
||||
/** The stored summary slice for `type` (count / latest / mean). */
|
||||
summary: DataSummary | null | undefined;
|
||||
/** Localized tile title (e.g. the `measurements.type*` label). */
|
||||
title: string;
|
||||
/** Leading glyph. */
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Single chart-line colour. Never used for card chrome. */
|
||||
color: string;
|
||||
/** Unit suffix on the readout + chart (e.g. "%", "bpm", "kJ", "score"). */
|
||||
unit?: string;
|
||||
/** Optional decimal precision for the readout. Defaults to 0. */
|
||||
fractionDigits?: number;
|
||||
/**
|
||||
* Below this many readings the tile shows the `<LearningGate>` instead of a
|
||||
* sparkline. Defaults to 3 — enough points for a line to read honestly.
|
||||
*/
|
||||
learningThreshold?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DeviceScoreTile({
|
||||
type,
|
||||
summary,
|
||||
title,
|
||||
icon: Icon,
|
||||
color,
|
||||
unit,
|
||||
fractionDigits = 0,
|
||||
learningThreshold = 3,
|
||||
className,
|
||||
}: DeviceScoreTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const count = summary?.count ?? 0;
|
||||
if (count === 0) return null;
|
||||
|
||||
const latest = summary?.latest ?? null;
|
||||
const mean = summary?.mean ?? null;
|
||||
const isLearning = count < learningThreshold;
|
||||
|
||||
const fmt = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="device-score-tile"
|
||||
data-metric={type}
|
||||
className={cn("gap-2 py-3 md:py-4", className)}
|
||||
>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
{latest != null ? (
|
||||
<span
|
||||
data-slot="device-score-latest"
|
||||
className="text-foreground shrink-0 text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLearning ? (
|
||||
<LearningGate
|
||||
compact
|
||||
bodySlot="device-score-learning"
|
||||
message={t("insights.deviceScore.learning")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[120px] [--chart-height:96px]">
|
||||
<HealthChartDynamicMini
|
||||
types={[type]}
|
||||
title={title}
|
||||
colors={[color]}
|
||||
unit={unit ?? ""}
|
||||
mini
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
</div>
|
||||
{mean != null ? (
|
||||
<p
|
||||
data-slot="device-score-mean"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.deviceScore.average", { value: fmt(mean) })}
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConnectedDeviceScoreTileProps
|
||||
extends Omit<DeviceScoreTileProps, "summary"> {
|
||||
/** Optional section heading rendered above the tile when it has data. */
|
||||
sectionTitle?: string;
|
||||
/** Optional Lucide glyph for the section heading. */
|
||||
sectionIcon?: ComponentType<{ className?: string }>;
|
||||
/** Optional section subtitle pinned to the heading's right edge. */
|
||||
sectionSubtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 — a {@link DeviceScoreTile} that reads its own summary from the
|
||||
* shared `["analytics", "summaries"]` slice, so it can be dropped onto a page
|
||||
* that uses the `HealthKitMetricPage` scaffold (which owns its own analytics
|
||||
* read internally) without threading a summary prop down. Reuses the same
|
||||
* cache the host page already populated — no extra round-trip. Returns null
|
||||
* until the data lands and when the metric has no readings, so a host page
|
||||
* stays byte-identical for users without that device signal.
|
||||
*/
|
||||
export function ConnectedDeviceScoreTile({
|
||||
type,
|
||||
sectionTitle,
|
||||
sectionIcon,
|
||||
sectionSubtitle,
|
||||
...tileProps
|
||||
}: ConnectedDeviceScoreTileProps) {
|
||||
const { data } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
const summary = data?.summaries?.[type];
|
||||
|
||||
if ((summary?.count ?? 0) === 0) return null;
|
||||
|
||||
const tile = (
|
||||
<DeviceScoreTile type={type} summary={summary} {...tileProps} />
|
||||
);
|
||||
|
||||
if (!sectionTitle || !sectionIcon) return tile;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="connected-device-score"
|
||||
data-metric={type}
|
||||
className="space-y-3"
|
||||
>
|
||||
<SectionHeading
|
||||
icon={sectionIcon}
|
||||
title={sectionTitle}
|
||||
action={
|
||||
sectionSubtitle ? (
|
||||
<p className="text-muted-foreground text-xs">{sectionSubtitle}</p>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{tile}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user