mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height existed in the measurement enum, signal registry, and FHIR export but had no web entry, no detail page, single non-sex-aware bands, and a split-brain MCP exposure. This closes those gaps. Web capture: the four signals join the manual measurement form, so a web-only self-hoster can log them (the iOS client and the measurements API already could). Detail pages: flip detailPage on for the four signals and mount each on the shared HealthKit metric template (chart, stat strip, Coach-read strip, assessment card) via routed sub-pages and tab-strip pills, the same detail spine every other metric carries. Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist (WHO men 94 / women 80 cm) now classify by the profile sex through the age/sex norm resolver the assessment already threads; the resolver also returns a sex-specific, age-independent band when no date of birth is set. Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the last in-range value is 0.49). MCP single source of truth: decouple the registry mcp facet from the Coach snapshot — a signal can be MCP-readable without being on the Coach surface. Mark the four physical signals mcp:true, derive the MCP rich-reads clinical set from the registry flag instead of a duplicate allowlist, and relax the invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and the environmental signals stay mcp:false and unreachable. compare_metric and detect_changepoints now advertise clinical-signal support to match get_metric_baseline.
541 lines
22 KiB
TypeScript
541 lines
22 KiB
TypeScript
/**
|
|
* Dashboard widget layout — persisted in User.dashboardWidgetsJson.
|
|
*
|
|
* Single source of truth for which widgets show on /, what order, and
|
|
* what the default layout is for new users. Null / missing = default.
|
|
*/
|
|
|
|
/**
|
|
* Every widget the dashboard layout knows about. The order of
|
|
* entries here is irrelevant — `DEFAULT_DASHBOARD_LAYOUT.widgets[].order`
|
|
* is the visual order. This array is exported so the API Zod
|
|
* `widgetIdEnum` (in `src/app/api/dashboard/widgets/route.ts`) can be
|
|
* built from a single source of truth — v1.4.16 Fix A5 root cause was
|
|
* the enum drifting away from the default-layout list, which silently
|
|
* 422'd every save.
|
|
*/
|
|
export const DASHBOARD_WIDGET_IDS = [
|
|
"weight",
|
|
"bp",
|
|
"pulse",
|
|
"bodyFat",
|
|
"mood",
|
|
"medications",
|
|
"sleep",
|
|
"steps",
|
|
"glucose",
|
|
"totalBodyWater",
|
|
"boneMass",
|
|
"bpInTarget",
|
|
"oxygenSaturation",
|
|
"achievements",
|
|
// v1.4.25 W8d — VO2 max trend tile (secondary-metric pattern). The
|
|
// strip tile is on by default and self-gates on having any VO2_MAX
|
|
// sample, so accounts that sync cardio fitness see it without enabling
|
|
// it in Settings → Dashboard, while empty accounts get a clean tile
|
|
// strip. Only the tile-strip surface; the chart row lives on the
|
|
// /insights/cardio-fitness sub-page.
|
|
"vo2Max",
|
|
// v1.4.32 — "Recent workouts" tile. Surfaces the three most-recent
|
|
// canonical workouts; self-gates on at least one row. Default-on
|
|
// (`visible: true`) because the maintainer wants new accounts to
|
|
// see the surface even when empty so the Apple-Health onboarding
|
|
// cue is discoverable from the dashboard rather than hidden
|
|
// behind Settings.
|
|
"recentWorkouts",
|
|
// v1.11.2 B5 — the v1.10 additive HealthKit signals become pinnable.
|
|
// Each maps 1:1 to a MeasurementType the server already stores;
|
|
// default-invisible so existing dashboards stay unchanged until the
|
|
// user opts in via Settings → Dashboard.
|
|
"cardioRecovery",
|
|
"sixMinuteWalk",
|
|
"stairAscentSpeed",
|
|
"stairDescentSpeed",
|
|
"breathingDisturbances",
|
|
"wristTemperature",
|
|
"falls",
|
|
"walkingSteadiness",
|
|
// v1.18.2 — Vorsorge (preventive-care reminders) becomes a first-class
|
|
// dashboard widget, exactly like `medications`: a chart-row summary card
|
|
// (no strip tile). Default-off so existing dashboards are unchanged; the
|
|
// user opts in via Settings → Dashboard. Always-on data surface (no
|
|
// toggleable module gate), so it behaves as a core opt-in tile.
|
|
"vorsorge",
|
|
] as const;
|
|
|
|
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
|
|
|
|
/**
|
|
* v1.11.2 B5 / HIGH-1 — widget ids that are WRITABLE (members of
|
|
* `DASHBOARD_WIDGET_IDS`, so the iOS pin PUT validates them) but have NO
|
|
* web render path: `src/app/page.tsx` renders strip tiles via hardcoded
|
|
* per-id blocks and never iterates an id→component map, so these ids draw
|
|
* nothing on the web dashboard. They are surfaced and pinned by the native
|
|
* iOS client only.
|
|
*
|
|
* They MUST stay in `DASHBOARD_WIDGET_IDS` (the iOS v0.14 Home-pin request
|
|
* needs the widgets PUT enum to accept them), but the web Settings →
|
|
* Dashboard list filters them out (see `dashboard-layout-section.tsx`) so a
|
|
* web user is never offered a toggle that silently does nothing on web.
|
|
*
|
|
* The 11 `DASHBOARD_IOS_ONLY_WIDGET_IDS` below are a separate concern: those
|
|
* are NOT writable (not in `DASHBOARD_WIDGET_IDS`) so they never reached the
|
|
* web Settings list in the first place. This set is specifically the
|
|
* writable-but-not-web-rendered ids that B5 introduced.
|
|
*/
|
|
export const IOS_PIN_ONLY_WIDGET_IDS = [
|
|
"cardioRecovery",
|
|
"sixMinuteWalk",
|
|
"stairAscentSpeed",
|
|
"stairDescentSpeed",
|
|
"breathingDisturbances",
|
|
"wristTemperature",
|
|
"falls",
|
|
"walkingSteadiness",
|
|
] as const satisfies readonly DashboardWidgetId[];
|
|
|
|
/**
|
|
* v1.7.0 — iOS-only widget ids the native client materialises in its
|
|
* own default layout but the server PUT enum does NOT yet accept. These
|
|
* are the HK-completeness tiles the iOS app added in its v0.5.2 / v0.7.0
|
|
* sweeps (`DashboardWidgetLayout.swift`); each maps 1:1 to a HealthLog
|
|
* `MeasurementType` the server already stores.
|
|
*
|
|
* They are deliberately kept OUT of `DASHBOARD_WIDGET_IDS` so the
|
|
* widgets PUT route's Zod enum + the on-read resolver are unchanged —
|
|
* widening the writable enum is a separate decision. This constant
|
|
* exists only so the dashboard snapshot can publish the full 27-id
|
|
* catalogue (`DASHBOARD_WIDGET_CATALOGUE_IDS`) the iOS cold-launch seed
|
|
* needs, letting the layout round-trip in one key without a second
|
|
* round-trip. See `.planning/ios-coord/v1.7.0-ios-convergence-locks.md`
|
|
* §2b.
|
|
*/
|
|
export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [
|
|
"restingHeartRate",
|
|
"hrv",
|
|
"walkingSpeed",
|
|
"walkingAsymmetry",
|
|
"walkingStepLength",
|
|
"bmi",
|
|
"bodyTemperature",
|
|
"walkingDoubleSupport",
|
|
"respiratoryRate",
|
|
"audioExposureEnvironment",
|
|
"audioExposureHeadphone",
|
|
] as const;
|
|
|
|
/**
|
|
* v1.7.0 — full widget-id catalogue: the 16 server-known ids plus the
|
|
* 11 iOS-only ids = 27 distinct ids. This is the authoritative set the
|
|
* iOS client expects in the snapshot's catalogue block so a cold-launch
|
|
* first-paint can seed every tile without a second round-trip. Pure
|
|
* superset of `DASHBOARD_WIDGET_IDS`; never used to gate the writable
|
|
* PUT enum.
|
|
*/
|
|
export const DASHBOARD_WIDGET_CATALOGUE_IDS = [
|
|
...DASHBOARD_WIDGET_IDS,
|
|
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
|
] as const;
|
|
|
|
export type DashboardWidgetCatalogueId =
|
|
(typeof DASHBOARD_WIDGET_CATALOGUE_IDS)[number];
|
|
|
|
export interface DashboardWidgetConfig {
|
|
// v1.7.0 — widened to the full 27-id catalogue so iOS-only ids
|
|
// round-trip through the stored layout. The web render path only
|
|
// looks up its own 16 ids; the other 11 ride along untouched.
|
|
id: DashboardWidgetCatalogueId;
|
|
/**
|
|
* Whether the widget shows up in the *charts* row (the lower section
|
|
* of the dashboard with the line graphs). The legacy single-toggle
|
|
* field — kept on the wire for back-compat with users who saved a
|
|
* layout pre-v1.4.15.
|
|
*/
|
|
visible: boolean;
|
|
/**
|
|
* v1.4.15 Fix 5 — independent visibility for the *strip tile* (the
|
|
* upper row of trend cards). When omitted, the resolver mirrors
|
|
* `visible` so existing saved layouts keep their previous behaviour
|
|
* (one toggle controls both surfaces). When set explicitly the user
|
|
* can show the chart but hide the tile, or show the tile but hide
|
|
* the chart, which is what the maintainer asked for in the v1.4.15 follow-up
|
|
* (memory `feedback_dashboard_top_tiles_selectable.md`).
|
|
*/
|
|
tileVisible?: boolean;
|
|
order: number;
|
|
}
|
|
|
|
/**
|
|
* v1.4.16 phase B8 — comparison baseline persisted alongside widgets.
|
|
*
|
|
* Research §7 Q3 settled on piggy-backing the comparison preference
|
|
* onto the existing `User.dashboardWidgetsJson` blob rather than
|
|
* carving a dedicated Prisma column — the field is ephemeral (a UI
|
|
* affordance, not an analytical attribute) and a JSON pivot here
|
|
* keeps the v1.4.16 release migration-free.
|
|
*
|
|
* Values:
|
|
* - "none" — comparison is off (default; pre-B8 behaviour).
|
|
* - "lastMonth" — overlay the matching window from 30 days earlier.
|
|
* - "lastYear" — overlay the matching window from 365 days earlier.
|
|
*
|
|
* The resolver defaults to "none" when the field is missing so legacy
|
|
* users see no behavioural change until they explicitly flip the
|
|
* toggle in Settings → Dashboard. Unknown values are clamped back to
|
|
* "none" so a stale client cannot poison the dashboard with a value
|
|
* the renderer doesn't know how to draw.
|
|
*/
|
|
export const COMPARISON_BASELINES = ["none", "lastMonth", "lastYear"] as const;
|
|
export type ComparisonBaseline = (typeof COMPARISON_BASELINES)[number];
|
|
|
|
function isComparisonBaseline(value: unknown): value is ComparisonBaseline {
|
|
return (
|
|
typeof value === "string" &&
|
|
(COMPARISON_BASELINES as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* v1.4.18 — per-chart overlay-prefs.
|
|
*
|
|
* The maintainer rolled back B1a's always-on chart overlays (gradient fill,
|
|
* personal-baseline reference line, target-zone shading) and asked for
|
|
* a per-chart switch surface so each chart can be tuned independently.
|
|
* The three toggles (7-day trend / Trend arrow / Target range) live in
|
|
* a popover on each chart card; their state persists per user via this
|
|
* map keyed by chart id.
|
|
*
|
|
* Defaults: every flag is `false`. Clean line is the new default;
|
|
* overlays are user-opt-in. Missing chart keys behave the same way
|
|
* (the chart wrapper fills in the default).
|
|
*
|
|
* The map piggy-backs on `User.dashboardWidgetsJson` instead of
|
|
* carving a new column — same pragmatism as the B8 comparison baseline.
|
|
*/
|
|
export const CHART_OVERLAY_KEYS = [
|
|
"bp",
|
|
"weight",
|
|
"bmi",
|
|
"pulse",
|
|
"bodyFat",
|
|
"mood",
|
|
"medications",
|
|
"sleep",
|
|
"steps",
|
|
// v1.4.25 W16a — VO2 max chart-row on /insights/pulse. Independent
|
|
// overlay-prefs slot so the cardio sub-page's VO2 chart-cog state
|
|
// doesn't bleed into the pulse chart sitting directly above it.
|
|
"vo2Max",
|
|
// v1.4.32 — wave-A HealthKit chart cards. Each one owns its own
|
|
// overlay-prefs slot so the chart-cog popover (7-day trend / Trend
|
|
// arrow / Target range / Comparison baseline) persists per metric.
|
|
"hrv",
|
|
"restingHr",
|
|
"oxygenSaturation",
|
|
"bodyTemperature",
|
|
"activeEnergy",
|
|
// v1.7.0 — chart-overlay slots for the previously-orphan metric
|
|
// sub-pages. Each card owns its own slot so the chart-cog popover
|
|
// persists per metric. Camel-case keys mirror the existing
|
|
// convention; one per new chart surface.
|
|
"bloodGlucose",
|
|
"totalBodyWater",
|
|
"boneMass",
|
|
"flightsClimbed",
|
|
"walkingRunningDistance",
|
|
"fatFreeMass",
|
|
"fatMass",
|
|
"muscleMass",
|
|
"skinTemperature",
|
|
"pulseWaveVelocity",
|
|
"vascularAge",
|
|
"visceralFat",
|
|
"audioExposureEnv",
|
|
"audioExposureHeadphone",
|
|
"timeInDaylight",
|
|
"walkingSteadiness",
|
|
"audioExposureEvent",
|
|
"respiratoryRate",
|
|
"leanBodyMass",
|
|
"walkingHeartRateAverage",
|
|
"walkingAsymmetry",
|
|
"walkingDoubleSupport",
|
|
"walkingStepLength",
|
|
"walkingSpeed",
|
|
// v1.10.0 — additive HealthKit signals (WX-A). One overlay-prefs slot
|
|
// per new chart surface; camel-case keys mirror the existing convention.
|
|
"cardioRecovery",
|
|
"wristTemperature",
|
|
"fallCount",
|
|
"sixMinuteWalkDistance",
|
|
"stairAscentSpeed",
|
|
"stairDescentSpeed",
|
|
"breathingDisturbances",
|
|
// v1.18.1 — chart-overlay slots for the rebuilt `/insights/recovery`
|
|
// page's device-native signal charts. One per chart surface.
|
|
"ansCharge",
|
|
"dayStrain",
|
|
"workoutStrain",
|
|
"cardioLoad",
|
|
"averageHeartRate",
|
|
"maxHeartRate",
|
|
"energyExpenditureKj",
|
|
// v1.25 — chart-overlay slots for the physical / clinical signal sub-pages.
|
|
// One per chart surface; camel-case keys mirror the existing convention.
|
|
"gripStrength",
|
|
"painNrs",
|
|
"waistCircumference",
|
|
"waistToHeight",
|
|
] as const;
|
|
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
|
|
|
export interface ChartOverlayPrefs {
|
|
showTrendIndicator: boolean;
|
|
showTrendArrow: boolean;
|
|
showTargetRange: boolean;
|
|
comparisonBaseline: ComparisonBaseline;
|
|
}
|
|
|
|
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: false,
|
|
showTargetRange: false,
|
|
comparisonBaseline: "none",
|
|
};
|
|
|
|
export type ChartOverlayPrefsMap = Partial<
|
|
Record<ChartOverlayKey, ChartOverlayPrefs>
|
|
>;
|
|
|
|
function isChartOverlayKey(value: string): value is ChartOverlayKey {
|
|
return (CHART_OVERLAY_KEYS as readonly string[]).includes(value);
|
|
}
|
|
|
|
function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
|
|
if (!value || typeof value !== "object") return DEFAULT_CHART_OVERLAY_PREFS;
|
|
const candidate = value as Partial<Record<keyof ChartOverlayPrefs, unknown>>;
|
|
return {
|
|
showTrendIndicator: candidate.showTrendIndicator === true,
|
|
showTrendArrow: candidate.showTrendArrow === true,
|
|
showTargetRange: candidate.showTargetRange === true,
|
|
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
|
|
? candidate.comparisonBaseline
|
|
: "none",
|
|
};
|
|
}
|
|
|
|
function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap {
|
|
if (!value || typeof value !== "object") return {};
|
|
const out: ChartOverlayPrefsMap = {};
|
|
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!isChartOverlayKey(key)) continue;
|
|
out[key] = coerceChartOverlayPrefs(raw);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface DashboardLayout {
|
|
version: number;
|
|
widgets: DashboardWidgetConfig[];
|
|
/** v1.4.16 phase B8 — see `COMPARISON_BASELINES` doc. */
|
|
comparisonBaseline?: ComparisonBaseline;
|
|
/** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */
|
|
chartOverlayPrefs?: ChartOverlayPrefsMap;
|
|
/**
|
|
* Dashboard hero (daily verdict) visibility. Piggy-backs on the
|
|
* layout blob like the B8 comparison baseline — a UI affordance, not
|
|
* an analytical attribute, so no Prisma migration. Opt-in: the
|
|
* resolver clamps anything that is not the literal `true` back to
|
|
* `false` (legacy blobs without the field keep the hero off; a
|
|
* stale client cannot poison it with a non-boolean), and the
|
|
* serializer persists the resolved boolean explicitly.
|
|
*/
|
|
heroVisible?: boolean;
|
|
}
|
|
|
|
const DASHBOARD_LAYOUT_VERSION = 1;
|
|
|
|
/**
|
|
* Default layout mirrors v1.1 behavior: core vitals always on, mood +
|
|
* body-fat show if data exists (see dashboard page for the data-conditional
|
|
* rendering). Ordering matches the visual order on / today.
|
|
*/
|
|
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
|
version: DASHBOARD_LAYOUT_VERSION,
|
|
// Hero (daily verdict) is off by default; users opt in via
|
|
// Settings → Dashboard.
|
|
heroVisible: false,
|
|
widgets: [
|
|
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
|
{ id: "bp", visible: true, tileVisible: true, order: 1 },
|
|
{ id: "pulse", visible: true, tileVisible: true, order: 2 },
|
|
{ id: "bodyFat", visible: true, tileVisible: true, order: 3 },
|
|
{ id: "mood", visible: true, tileVisible: true, order: 4 },
|
|
{ id: "bpInTarget", visible: true, tileVisible: true, order: 5 },
|
|
{ id: "medications", visible: true, tileVisible: true, order: 6 },
|
|
// v1.20.0 — sleep / steps / glucose strip tiles default-on so accounts
|
|
// that sync these signals discover them on / without hunting through
|
|
// Settings → Dashboard. Each follows the VO2max precedent: the strip
|
|
// tile is on (`tileVisible: true`) and self-gates on having data
|
|
// (`hasSleep` / `hasSteps` / `showGlucoseCards` in page.tsx), so an
|
|
// empty account still gets a clean dashboard. The chart row stays off
|
|
// (`visible: false`) — those charts live on the /insights sub-pages.
|
|
{ id: "sleep", visible: false, tileVisible: true, order: 7 },
|
|
{ id: "steps", visible: false, tileVisible: true, order: 8 },
|
|
{ id: "glucose", visible: false, tileVisible: true, order: 9 },
|
|
// totalBodyWater / boneMass / oxygenSaturation carry as catalogue ids
|
|
// for the iOS layout round-trip only — they have no web render block or
|
|
// data-floor flag in page.tsx, so flipping their defaults would surface
|
|
// nothing on web. Their data IS discoverable via the /insights sub-pages.
|
|
// Kept false until a web render path lands (see .planning/v1200-backlog.md).
|
|
{ id: "totalBodyWater", visible: false, tileVisible: false, order: 10 },
|
|
{ id: "boneMass", visible: false, tileVisible: false, order: 11 },
|
|
{ id: "oxygenSaturation", visible: false, tileVisible: false, order: 12 },
|
|
// v1.4.15 phase-B4 — recent-unlocks card (dashboard surface for the
|
|
// gamification feature). Visible by default — the card is small,
|
|
// self-gates on having any achievements (otherwise it shows a brief
|
|
// "no achievements yet" + link), and maintainer-asked-for-it explicitly.
|
|
// `tileVisible` is forced false because there is no tile surface
|
|
// for this widget; only the chart-row card.
|
|
{ id: "achievements", visible: true, tileVisible: false, order: 13 },
|
|
// v1.4.25 W8d — VO2 max trend tile. The strip tile is on by default
|
|
// (`tileVisible: true`) so accounts that already sync a VO2max sample
|
|
// see cardio fitness without hunting through Settings → Dashboard; the
|
|
// tile self-gates on having any VO2_MAX sample (see `showVo2Tile` in
|
|
// page.tsx), so accounts with no sample still get a clean dashboard.
|
|
// The chart row stays off (`visible: false`) — VO2max charts live on
|
|
// the /insights/cardio-fitness and /insights/pulse sub-pages.
|
|
{ id: "vo2Max", visible: false, tileVisible: true, order: 14 },
|
|
// v1.4.32 — Recent workouts dashboard tile. Default-visible so
|
|
// brand-new accounts discover the workout surface without
|
|
// hunting through Settings; the tile self-gates on a non-empty
|
|
// workouts list and renders an Apple-Health onboarding hint
|
|
// otherwise.
|
|
{ id: "recentWorkouts", visible: true, tileVisible: true, order: 15 },
|
|
// v1.11.2 B5 — v1.10 additive HealthKit signals, pinnable but
|
|
// default-invisible on both surfaces. The user opts in via
|
|
// Settings → Dashboard; each tile self-gates on having any sample.
|
|
{ id: "cardioRecovery", visible: false, tileVisible: false, order: 16 },
|
|
{ id: "sixMinuteWalk", visible: false, tileVisible: false, order: 17 },
|
|
{ id: "stairAscentSpeed", visible: false, tileVisible: false, order: 18 },
|
|
{ id: "stairDescentSpeed", visible: false, tileVisible: false, order: 19 },
|
|
{
|
|
id: "breathingDisturbances",
|
|
visible: false,
|
|
tileVisible: false,
|
|
order: 20,
|
|
},
|
|
{ id: "wristTemperature", visible: false, tileVisible: false, order: 21 },
|
|
{ id: "falls", visible: false, tileVisible: false, order: 22 },
|
|
{ id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 },
|
|
// v1.18.2 — Vorsorge summary card. Chart-row only (no strip tile), so
|
|
// `tileVisible` is forced false. v1.20.0 — flipped `visible: true` so
|
|
// preventive-care reminders surface on / by default; the card self-gates
|
|
// (see page.tsx) and shows nothing for accounts without due reminders.
|
|
{ id: "vorsorge", visible: true, tileVisible: false, order: 24 },
|
|
],
|
|
};
|
|
|
|
export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
|
if (!raw || typeof raw !== "object") return DEFAULT_DASHBOARD_LAYOUT;
|
|
const candidate = raw as Partial<DashboardLayout>;
|
|
if (
|
|
typeof candidate.version !== "number" ||
|
|
!Array.isArray(candidate.widgets)
|
|
) {
|
|
return DEFAULT_DASHBOARD_LAYOUT;
|
|
}
|
|
|
|
// Drop widget ids outside the full 27-id catalogue. v1.4.28 retired
|
|
// the `glp1` tile; any user who saved a layout before then still has
|
|
// the orphan id in `dashboardWidgetsJson`, and a stale-enum PUT would
|
|
// reject the entire blob on the next save round-trip. Filtering on
|
|
// read keeps the GET shape current-build-safe and lets the Settings
|
|
// UI re-PUT a clean array.
|
|
//
|
|
// v1.7.0 — the catalogue is the 27-id superset (16 web-known + 11
|
|
// iOS-only). The 11 iOS-only ids are RETAINED here so a layout the
|
|
// native client persisted round-trips intact on GET; the web render
|
|
// path looks up only its own 16 ids and silently ignores the rest
|
|
// (it never iterates id→component generically). A genuinely-unknown
|
|
// id (typo / retired tile) outside the 27 still drops.
|
|
const knownIds = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
|
|
const filtered = candidate.widgets.filter((w) => knownIds.has(w.id));
|
|
|
|
// Merge with defaults so new WEB widgets introduced in later versions
|
|
// show up automatically (invisible by default, users opt-in). Only the
|
|
// 16 web defaults are auto-appended — iOS-only ids only ever appear in
|
|
// a layout once a native client has explicitly sent them, so they are
|
|
// never seeded for a web-only account.
|
|
const savedIds = new Set(filtered.map((w) => w.id));
|
|
const missing = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
|
|
(w) => !savedIds.has(w.id),
|
|
);
|
|
const maxOrder = Math.max(0, ...filtered.map((w) => w.order));
|
|
const appended = missing.map((w, i) => ({
|
|
...w,
|
|
visible: false, // default-invisible on auto-upgrade
|
|
tileVisible: false,
|
|
order: maxOrder + 1 + i,
|
|
}));
|
|
|
|
// Default `tileVisible` to mirror `visible` for legacy entries that
|
|
// never had the field (the v1.4.15 schema upgrade). Users who saved
|
|
// a layout before this release see no behavioural change until they
|
|
// explicitly toggle the new strip switch.
|
|
const normalized = filtered.map((w) => ({
|
|
...w,
|
|
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
|
}));
|
|
|
|
return {
|
|
version: DASHBOARD_LAYOUT_VERSION,
|
|
widgets: [...normalized, ...appended].sort((a, b) => a.order - b.order),
|
|
// v1.4.16 phase B8 — clamp unknown / missing comparison values to
|
|
// "none" so the dashboard renders the pre-B8 behaviour for legacy
|
|
// layouts and refuses to act on a value it doesn't recognise.
|
|
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
|
|
? candidate.comparisonBaseline
|
|
: "none",
|
|
// v1.4.18 — per-chart overlay prefs. Drop unknown chart keys and
|
|
// coerce non-boolean toggle values back to false so a stale
|
|
// client cannot poison the dashboard with values the renderer
|
|
// doesn't know how to draw.
|
|
chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs),
|
|
// Hero visibility — anything that is not the literal `true`
|
|
// clamps to `false` (default-off for legacy blobs and malformed
|
|
// values alike; the hero is opt-in).
|
|
heroVisible: candidate.heroVisible === true,
|
|
};
|
|
}
|
|
|
|
export function serializeDashboardLayout(
|
|
layout: DashboardLayout,
|
|
): DashboardLayout {
|
|
return {
|
|
version: DASHBOARD_LAYOUT_VERSION,
|
|
widgets: layout.widgets
|
|
.slice()
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((w, i) => ({
|
|
...w,
|
|
// Persist tileVisible explicitly so a follow-up read of the
|
|
// saved layout doesn't have to re-mirror from `visible` and
|
|
// discard the user's actual choice.
|
|
tileVisible:
|
|
typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
|
order: i, // normalize to 0-based dense order
|
|
})),
|
|
// v1.4.16 phase B8 — persist the comparison baseline explicitly.
|
|
comparisonBaseline: isComparisonBaseline(layout.comparisonBaseline)
|
|
? layout.comparisonBaseline
|
|
: "none",
|
|
// v1.4.18 — persist per-chart overlay prefs verbatim through the
|
|
// same coercion the resolver runs so the wire shape is stable.
|
|
chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs),
|
|
// Persist hero visibility explicitly (same clamp as the resolver)
|
|
// so a re-read never has to guess the default.
|
|
heroVisible: layout.heroVisible === true,
|
|
};
|
|
}
|