mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Show the HRV tile for an account whose HRV is RMSSD
HRV lives under two measurement types and they are not the same measure: Apple Health / Fitbit / Google Health write SDNN as `HEART_RATE_VARIABILITY`, while Oura / Polar / WHOOP — and a bridge pushing a watch that reports it that way — write nightly RMSSD as `HRV_RMSSD`. Every HRV surface unions the two: `/insights/hrv` through `fallbackMeasurementType`, the Coach through `COACH_SOURCE_MEASUREMENT_TYPES.hrv`, the MCP reads through `withHrvFallback`. `sub-page-metric.ts` even says listing both is what keeps "the tab, pill, and dashboard tile lit whichever source the user has". The dashboard tile did not. It read SDNN alone, so an RMSSD-only account got a Settings → Dashboard toggle that turned on and rendered nothing, with no explanation, while the insights page charted the same readings — which is why it reads as a broken layout rather than a missing series. `pickHrvSummary` picks the series with the same rule `withHrvFallback` uses: the primary wins whenever it has any rows, the fallback only when it has none. Never a merge — the two share `ms` but measure different things, so blending them would invent a number no device reported. The caller gets the winning type back, because the freshness caption has to read the series on show and the label has to name the measure when it is not SDNN (`· RMSSD`, as the insights chart title already does). Second, less visible half: `HRV_RMSSD` is recovery-owned in `METRIC_STATUS_MODULE_OWNERS`, but the dashboard snapshot gates off `SUMMARY_TYPE_MODULE`, a different map, and that one did not list it. The omission was inert while the tile read SDNN alone; adding the fallback without it would hand a recovery-off account a tile built from recovery-owned rows — the exact hazard the owner map documents. The entry goes in beside the four sibling recovery types, so the gate stays server side and the client keeps no competing copy.
This commit is contained in:
5 files changed
+277
-3
No files matched your search
@@ -6,6 +6,7 @@
|
||||
* live in one cohesive module. `page.tsx` re-exports them so the existing
|
||||
* `../page` test imports stay valid.
|
||||
*/
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
@@ -129,3 +130,55 @@ export function resolveChartRowPlaceholderCount(
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** The two measurement types the HRV surface reads, primary first. */
|
||||
export type HrvSummaryType = "HEART_RATE_VARIABILITY" | "HRV_RMSSD";
|
||||
|
||||
/**
|
||||
* Which HRV series backs the dashboard tile.
|
||||
*
|
||||
* HRV is stored under two types and they are not the same measure: Apple
|
||||
* Health / Fitbit / Google Health write SDNN as `HEART_RATE_VARIABILITY`,
|
||||
* while Oura / Polar / WHOOP — and a Garmin-style feed pushed in through a
|
||||
* bridge — write nightly RMSSD as `HRV_RMSSD`. Every other HRV surface
|
||||
* already unions the two: `/insights/hrv` passes `fallbackMeasurementType`
|
||||
* (`app/insights/hrv/page.tsx`), the Coach resolves both
|
||||
* (`COACH_SOURCE_MEASUREMENT_TYPES.hrv`), and the MCP reads swap through
|
||||
* `withHrvFallback` (`lib/mcp/rich-reads.ts`). The catalogue entry in
|
||||
* `lib/insights/sub-page-metric.ts` even says listing both is what "keeps the
|
||||
* tab, pill, and dashboard tile lit whichever source the user has" — but the
|
||||
* tile read SDNN alone, so an RMSSD-only account got a Settings toggle that
|
||||
* silently rendered nothing while the insights page charted the data.
|
||||
*
|
||||
* Same rule as `withHrvFallback`: the primary wins whenever it has ANY rows,
|
||||
* and the fallback is taken only when it has none. Deliberately not a merge —
|
||||
* SDNN and RMSSD share `ms` but measure different things, so blending them
|
||||
* would invent a number no device reported. That is why the caller is handed
|
||||
* the winning `type` back: the freshness caption has to read the series being
|
||||
* shown, and the label has to name the measure when it is not SDNN.
|
||||
*
|
||||
* Module gating belongs upstream and must NOT be repeated here. `HRV_RMSSD`
|
||||
* is a `recovery`-owned type, so `SUMMARY_TYPE_MODULE` carries it and
|
||||
* `gateSummariesByModules()` strips it out of `tiles.summaries` before the
|
||||
* client sees anything — a `recovery`-off account has no key to fall back to.
|
||||
* That entry was added WITH this helper: while the tile read SDNN alone the
|
||||
* omission was inert, and a client-side gate here would be a second source of
|
||||
* truth that drifts from the server's.
|
||||
*/
|
||||
export function pickHrvSummary(
|
||||
summaries: Record<string, DataSummary> | undefined,
|
||||
): { summary: DataSummary | null; type: HrvSummaryType } {
|
||||
const primary = summaries?.HEART_RATE_VARIABILITY;
|
||||
if ((primary?.count ?? 0) > 0) {
|
||||
return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" };
|
||||
}
|
||||
const fallback = summaries?.HRV_RMSSD;
|
||||
if ((fallback?.count ?? 0) > 0) {
|
||||
return { summary: fallback ?? null, type: "HRV_RMSSD" };
|
||||
}
|
||||
// Neither has rows. Report the primary type so the caller's stale-days
|
||||
// lookup and label stay on SDNN for the empty case — the tile is hidden
|
||||
// either way, and reporting the fallback here would make an account with
|
||||
// no HRV at all look like a ring user.
|
||||
return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" };
|
||||
}
|
||||
Reference in new issue
Block a user