/** * 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 >; 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>; 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)) { 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 (`` 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; 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), }; }