mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(insights): surface the same-time baseline on Today and in the grid
The rail gains a same_time_baseline kind and a server builder, which is the growth path that type documents for itself. It speaks only when today is actually outside the typical band: a card that reports doing exactly what you usually do teaches people to stop reading the rail. Dismissing it is keyed by day and metric, not by the hour, so it stays dismissed instead of coming back every sixty minutes. The Insights grid gains a section of four tiles, one per cumulative metric, each honestly quiet until the history accrues. The two-trajectory chart it needs did not exist, so DeltaSparkline gains a second mode rather than a second chart family: the comparison curve rides the same container, the same fixed height and the same shared y-domain as a dashed muted line behind the live one, which is the only way the two can be read against each other. Six locales. The wipe and backup plans learn about the new table: the profile cannot be rebuilt once the samples are gone, so it is carried, not derived.
This commit is contained in:
1 parent
b2955beffd
commit
6bd8d387ae
24 files changed
+1204
-32
No files matched your search
@@ -22,6 +22,7 @@ import {
|
||||
Activity,
|
||||
Award,
|
||||
CalendarClock,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
MessageCircle,
|
||||
Pill,
|
||||
@@ -53,6 +54,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
|
||||
milestone: Award,
|
||||
ecg_new_recording: HeartPulse,
|
||||
tension_window: Activity,
|
||||
same_time_baseline: Footprints,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,20 +10,49 @@ import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
* (`@/components/charts/chart-runtime`); its fixed 40 px container is owned
|
||||
* by the tile, so the async gap paints an empty band of identical size —
|
||||
* zero layout shift, pixel-identical once mounted.
|
||||
*
|
||||
* Two modes, one chart.
|
||||
*
|
||||
* - **Trend** (the original): one series, area-filled under a sentiment
|
||||
* stroke. Every baseline tile in the vitals grid uses this.
|
||||
* - **Comparison** (v1.34.0): the same series against a second one, drawn
|
||||
* index-aligned behind it as a dashed muted line with no fill. The
|
||||
* same-time baseline needs two trajectories on one pair of axes — today's
|
||||
* running total and the person's typical one — and nothing in the tree
|
||||
* drew that. It is a second mode here rather than a new chart family
|
||||
* because it is the same picture with one more line: same container, same
|
||||
* fixed height, same shared y-domain, so the two curves are read against
|
||||
* each other and not against two different scales.
|
||||
*
|
||||
* The comparison line renders as an `<Area>` with no fill rather than a
|
||||
* `<Line>` so the chart stays a plain `AreaChart` and both series contribute
|
||||
* to the `dataMin`/`dataMax` domain automatically.
|
||||
*/
|
||||
export function DeltaSparkline({
|
||||
data,
|
||||
strokeVar,
|
||||
comparisonLabel,
|
||||
}: {
|
||||
/** `{ i, v }` points, already length-guarded (≥2) by the tile. */
|
||||
data: Array<{ i: number; v: number }>;
|
||||
/**
|
||||
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
|
||||
* `b` on a point is the comparison series' value at the same index —
|
||||
* present on every point or on none.
|
||||
*/
|
||||
data: Array<{ i: number; v: number; b?: number }>;
|
||||
/** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */
|
||||
strokeVar: string;
|
||||
/**
|
||||
* Accessible name for the comparison curve, when one is present. Passed
|
||||
* through to the series so a screen reader reaching the chart is told what
|
||||
* the second line is rather than meeting an unnamed shape.
|
||||
*/
|
||||
comparisonLabel?: string;
|
||||
}) {
|
||||
// A stable per-instance id for the gradient <defs>. Deriving it from a
|
||||
// label slug collides when two tiles share a localized label (the gradient
|
||||
// fill on the second tile would not resolve); useId() is collision-free.
|
||||
const fillId = useId();
|
||||
const hasComparison = data.some((point) => point.b !== undefined);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
|
||||
@@ -34,6 +63,21 @@ export function DeltaSparkline({
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
{/* The comparison curve sits UNDER the live one so today's line is
|
||||
never occluded by the reference it is being judged against. */}
|
||||
{hasComparison ? (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="b"
|
||||
name={comparisonLabel}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 3"
|
||||
fill="none"
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
) : null}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
|
||||
@@ -53,6 +53,17 @@ export interface SparklineDeltaTileProps {
|
||||
* small fixed height.
|
||||
*/
|
||||
series?: number[];
|
||||
/**
|
||||
* v1.34.0 — an optional second curve drawn behind `series` on the same
|
||||
* axes, index-aligned with it. This is what turns the sparkline into the
|
||||
* two-trajectory comparison the same-time baseline needs: today's running
|
||||
* total against the person's typical one. Ignored unless it is exactly as
|
||||
* long as `series` — two curves of different lengths would be read as
|
||||
* aligned when they are not, which is worse than one curve.
|
||||
*/
|
||||
comparisonSeries?: number[];
|
||||
/** Accessible name for `comparisonSeries` (e.g. "your typical day"). */
|
||||
comparisonLabel?: string;
|
||||
/** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */
|
||||
delta?: number | null;
|
||||
/** Whether an up move is good / bad / neutral for this metric. */
|
||||
@@ -90,6 +101,8 @@ export function SparklineDeltaTile({
|
||||
unit,
|
||||
icon: Icon,
|
||||
series,
|
||||
comparisonSeries,
|
||||
comparisonLabel,
|
||||
delta = null,
|
||||
directionSentiment = "neutral",
|
||||
framing,
|
||||
@@ -117,8 +130,16 @@ export function SparklineDeltaTile({
|
||||
return `${sign}${fmt.number(Math.abs(d), precision)}`;
|
||||
};
|
||||
|
||||
// The comparison curve only rides along when it lines up point-for-point;
|
||||
// a mismatched length would draw two curves that look aligned and are not.
|
||||
const paired =
|
||||
comparisonSeries && series && comparisonSeries.length === series.length
|
||||
? comparisonSeries
|
||||
: null;
|
||||
const sparkData =
|
||||
series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
|
||||
series && series.length >= 2
|
||||
? series.map((v, i) => (paired ? { i, v, b: paired[i] } : { i, v }))
|
||||
: null;
|
||||
|
||||
// The sparkline tints with the trend sentiment so it reads "same signal"
|
||||
// as the arrow; neutral metrics ride the muted-foreground line.
|
||||
@@ -199,7 +220,11 @@ export function SparklineDeltaTile({
|
||||
className="mt-3 h-10 w-full"
|
||||
data-slot="sparkline-delta-tile-spark"
|
||||
>
|
||||
<DeltaSparkline data={sparkData} strokeVar={strokeVar} />
|
||||
<DeltaSparkline
|
||||
data={sparkData}
|
||||
strokeVar={strokeVar}
|
||||
comparisonLabel={comparisonLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -42,12 +42,25 @@ export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [
|
||||
{ metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" },
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.34.0 — the cumulative metrics that carry a same-time baseline. Order is
|
||||
* the order the section renders in: steps leads because it is the number
|
||||
* people actually check, and the one a "how am I doing" question is usually
|
||||
* about.
|
||||
*/
|
||||
export const SECTION_CUMULATIVE: string[] = [
|
||||
"ACTIVITY_STEPS",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"FLIGHTS_CLIMBED",
|
||||
];
|
||||
|
||||
/**
|
||||
* The full set of tokens the overview reads in one batch — the five wellness
|
||||
* scores + the four derived re-frames + one baseline per vital (minus HRV,
|
||||
* which has its own balance tile) + the mobility/body metrics. The
|
||||
* coincident-deviation flag is read by the dedicated "Today's signal" card,
|
||||
* not here.
|
||||
* which has its own balance tile) + the mobility/body metrics + one same-time
|
||||
* baseline per cumulative metric. The coincident-deviation flag is read by the
|
||||
* dedicated "Today's signal" card, not here.
|
||||
*/
|
||||
export function dashboardTokens(): DerivedBatchToken[] {
|
||||
const tokens: DerivedBatchToken[] = [
|
||||
@@ -69,6 +82,9 @@ export function dashboardTokens(): DerivedBatchToken[] {
|
||||
for (const { metric } of SECTION_MOBILITY) {
|
||||
tokens.push({ metric });
|
||||
}
|
||||
for (const type of SECTION_CUMULATIVE) {
|
||||
tokens.push({ metric: "SAME_TIME_BASELINE", type });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ import { type DerivedBatchRead } from "./use-derived-metric";
|
||||
import {
|
||||
SECTION_VITALS,
|
||||
SECTION_MOBILITY,
|
||||
SECTION_CUMULATIVE,
|
||||
type DashboardDerived,
|
||||
} from "./use-dashboard-derived";
|
||||
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
|
||||
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
@@ -34,6 +36,7 @@ import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"
|
||||
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
|
||||
import type { BmiValue } from "@/lib/insights/derived/bmi";
|
||||
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
|
||||
import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
@@ -129,6 +132,14 @@ const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
|
||||
WRIST_TEMPERATURE: "wrist-temperature",
|
||||
};
|
||||
|
||||
/** Same-time-baseline `MeasurementType` → layout tile id (routed slug). */
|
||||
const CUMULATIVE_TILE_LAYOUT_ID: Record<string, string> = {
|
||||
ACTIVITY_STEPS: "steps",
|
||||
ACTIVE_ENERGY_BURNED: "active-energy",
|
||||
WALKING_RUNNING_DISTANCE: "walking-distance",
|
||||
FLIGHTS_CLIMBED: "flights-climbed",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.15.11 W2c — `true` when the layout marks this tile id visible (or the
|
||||
* layout does not enumerate the id, in which case the tile is always-on). A
|
||||
@@ -308,6 +319,118 @@ function BaselineTile({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.34.0 — the same-time baseline tile for one cumulative metric.
|
||||
*
|
||||
* Every other tile in this grid frames a latest reading against a band. A
|
||||
* cumulative metric has no latest reading, so this one frames today's running
|
||||
* total against the person's own typical total at the same local hour, and the
|
||||
* sparkline carries both trajectories on one pair of axes rather than one.
|
||||
*
|
||||
* The states are the states the rest of the grid already has, with one
|
||||
* addition that matters more here than anywhere else: a baseline built from
|
||||
* three days is not a baseline, so below the floor the tile says it is still
|
||||
* learning and shows how far along it is. It never renders a number it does
|
||||
* not have the history for.
|
||||
*/
|
||||
function SameTimeBaselineTile({
|
||||
type,
|
||||
read,
|
||||
isLoading,
|
||||
}: TileProps & {
|
||||
type: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const data = read<SameTimeBaselineValue>({
|
||||
metric: "SAME_TIME_BASELINE",
|
||||
type,
|
||||
});
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Footprints;
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const label = labelKey ? t(labelKey) : type;
|
||||
const tileId = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
|
||||
|
||||
if (data.status === "insufficient") {
|
||||
// Absent, not gated. No sub-daily rows today (every daily-total source —
|
||||
// Fitbit, Withings, Polar — is permanently here), an unsupported type, or
|
||||
// a day whose first hour has not finished yet. None of these is a state
|
||||
// the user can act on, so the tile does not render at all rather than
|
||||
// occupying the grid with an apology.
|
||||
if (data.reason !== "learning_usual_day") return null;
|
||||
return (
|
||||
<div
|
||||
data-slot="same-time-tile"
|
||||
data-metric={tileId}
|
||||
data-state="learning"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<TileHeader
|
||||
icon={Icon}
|
||||
title={label}
|
||||
titleAs="h2"
|
||||
titleClassName="truncate"
|
||||
/>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="same-time-tile-learning"
|
||||
>
|
||||
{t("insights.derived.sameTime.learning", {
|
||||
count: data.coverage.historyDays,
|
||||
target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
|
||||
})}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
const unit = unitDisplay.unitFor(type);
|
||||
const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
|
||||
// A transformed distance reads in kilometres and wants a decimal; a step,
|
||||
// flight or kilocalorie count is a whole number and a decimal on it is noise.
|
||||
const precision = unitDisplay.isTransformed(type) ? 1 : 0;
|
||||
// The comparison is anchored to the END of `asOfHour`, which is the top of
|
||||
// the following hour on the clock. 24-hour form, built from the number the
|
||||
// server already resolved — deriving a locale-formatted instant on the
|
||||
// client from a server-side local hour is how a hydration mismatch starts.
|
||||
const byClock = `${String(v.asOfHour + 1).padStart(2, "0")}:00`;
|
||||
|
||||
return (
|
||||
<div data-slot="same-time-tile" data-metric={tileId} data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={label}
|
||||
value={toShown(v.todayValue)}
|
||||
unit={unit}
|
||||
icon={Icon}
|
||||
series={v.todayCurve.map(toShown)}
|
||||
comparisonSeries={v.typicalCurve.map(toShown)}
|
||||
comparisonLabel={t("insights.derived.sameTime.comparisonLabel")}
|
||||
delta={unitDisplay.toDisplayDelta(type, v.delta)}
|
||||
directionSentiment="up-good"
|
||||
precision={precision}
|
||||
framing={t("insights.derived.sameTime.framing", {
|
||||
time: byClock,
|
||||
typical: fmt.number(toShown(v.typicalValue), precision),
|
||||
unit,
|
||||
})}
|
||||
provenance={
|
||||
<MetricProvenance
|
||||
metric="SAME_TIME_BASELINE"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
}
|
||||
footer={<LearnMoreLink concept={tileId} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough
|
||||
* re-frame). Surfaces the device's estimated distance + trend always; the
|
||||
@@ -632,6 +755,29 @@ function hasRenderableMobility(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the batch holds at least one same-time tile worth painting. Mirrors
|
||||
* the tile's own gate exactly: only an `ok` value or the honest "still
|
||||
* learning" state counts, so the section heading never strands over a grid
|
||||
* that turned out to be empty (every daily-total source lands there).
|
||||
*/
|
||||
function hasRenderableSameTime(
|
||||
read: DerivedBatchRead,
|
||||
layout: InsightsLayout | undefined,
|
||||
): boolean {
|
||||
for (const type of SECTION_CUMULATIVE) {
|
||||
if (!tileVisible(layout, CUMULATIVE_TILE_LAYOUT_ID[type] ?? type)) continue;
|
||||
const data = read<SameTimeBaselineValue>({
|
||||
metric: "SAME_TIME_BASELINE",
|
||||
type,
|
||||
});
|
||||
if (!data) continue;
|
||||
if (data.status === "ok") return true;
|
||||
if (data.reason === "learning_usual_day") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -724,6 +870,20 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layout, read, isLoading]);
|
||||
|
||||
const cumulativeTiles = useMemo(() => {
|
||||
return SECTION_CUMULATIVE.map((type) => {
|
||||
const id = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
|
||||
return {
|
||||
id,
|
||||
order: tileOrder(layout, id),
|
||||
node: <SameTimeBaselineTile key={type} type={type} {...tileProps} />,
|
||||
};
|
||||
})
|
||||
.filter((e) => tileVisible(layout, e.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layout, read, isLoading]);
|
||||
|
||||
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
|
||||
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
|
||||
// vanishes. Surface a compact message + a Retry that refetches the one
|
||||
@@ -761,6 +921,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
// pops in below it — the CLS this pass removes.
|
||||
const showSection = isLoading || hasRenderableVital(read, layout);
|
||||
const showMobility = !isLoading && hasRenderableMobility(read, layout);
|
||||
const showSameTime = !isLoading && hasRenderableSameTime(read, layout);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -802,6 +963,30 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{showSameTime && (
|
||||
<section
|
||||
data-slot="vitals-same-time"
|
||||
aria-label={t("insights.derived.sameTime.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Footprints}
|
||||
title={t("insights.derived.sameTime.sectionTitle")}
|
||||
subtitle={t("insights.derived.sameTime.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-same-time-grid"
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4",
|
||||
// A lone tile spans the full width rather than orphaning a
|
||||
// one-third column, matching the sections above it.
|
||||
cumulativeTiles.length > 1 && "sm:grid-cols-2 lg:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{cumulativeTiles.map((tile) => tile.node)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{showMobility && (
|
||||
<section
|
||||
data-slot="vitals-mobility"
|
||||
|
||||
Reference in new issue
Block a user