mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
merge: s8-baselines into release/v1.34.0
This commit is contained in:
commit
b14943ea1b
32 files changed
+2312
-30
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}
|
||||
|
||||
|
||||
@@ -178,4 +178,14 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
|
||||
},
|
||||
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
|
||||
},
|
||||
SAME_TIME_BASELINE: {
|
||||
methodKey: "insights.derived.composite.SAME_TIME_BASELINE.method",
|
||||
standard: {
|
||||
// Same robust median ± k·MAD estimator as the vitals baseline, applied
|
||||
// to the window's totals at one local hour rather than to daily means.
|
||||
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
|
||||
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat",
|
||||
},
|
||||
};
|
||||
@@ -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