Files
HealthLog/src/lib/dashboard-layout.ts
T
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00

290 lines
11 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.16 phase D reconcile (CRITICAL C2) — dashboard preview of the
// top severity-ordered AI recommendations + "View all" CTA. Has only
// a `visible` (chart-row) surface; tileVisible is forced false because
// there's no tile-strip representation for it.
"insightsPreview",
] as const;
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
export interface DashboardWidgetConfig {
id: DashboardWidgetId;
/**
* 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 Marc 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];
export 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.
*
* Marc 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",
"pulse",
"mood",
"medications",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
export interface ChartOverlayPrefs {
showTrendIndicator: boolean;
showTrendArrow: boolean;
showTargetRange: boolean;
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
};
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,
};
}
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;
}
export 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,
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 },
{ id: "sleep", visible: false, tileVisible: false, order: 7 },
{ id: "steps", visible: false, tileVisible: false, order: 8 },
{ id: "glucose", visible: false, tileVisible: false, order: 9 },
{ 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 Marc-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.16 phase D reconcile (CRITICAL C2) — AI insights preview
// anchored at the top of the chart row. Default visible so users
// discover the feature without hunting through Settings →
// Dashboard; self-gates on having a generated payload AND at least
// one recommendation (`<InsightsCardPreview>` returns null
// otherwise). `tileVisible` forced false because the preview lives
// in the chart row, not the tile strip. The dashboard page
// renders this entry independently of the sorted `charts[]` array
// so it stays pinned above charts even if the user reorders other
// widgets; only the boolean visibility flag is consumed.
{ id: "insightsPreview", visible: true, tileVisible: false, order: 14 },
],
};
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;
}
// Merge with defaults so new widgets introduced in later versions show up
// automatically (invisible by default, users opt-in).
const savedIds = new Set(candidate.widgets.map((w) => w.id));
const missing = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => !savedIds.has(w.id),
);
const maxOrder = Math.max(0, ...candidate.widgets.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 = candidate.widgets.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),
};
}
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),
};
}