Files
HealthLog/src/lib/insights-layout.ts
T
Marc-André Bombeck d81ab69dc8 feat(insights): widen the layout tile-id set to the full metric universe
Derive `INSIGHTS_TILE_IDS` from `SUB_PAGE_SLUGS` rather than maintaining a
separate 14-id list. The layout tile-id universe and the routed tab strip
now share one source of truth, so a client can persist a layout covering
every metric page the web exposes (37 slugs + the overview mother page)
instead of tripping a 422 on the ~25 slugs the fixed allow-list did not
know about.

Rebuild `DEFAULT_INSIGHTS_LAYOUT` from the derived id list with an explicit
default-visible set; the long-tail HealthKit, body-composition, mobility,
and audio tiles stay default-invisible. The accepted set stays closed — an
unknown id still 422s — and the legacy German aliases keep round-tripping,
so no persisted layout breaks.

Regenerate the OpenAPI contract and extend the route deck: a PUT carrying
the previously-rejected slugs now succeeds, a full-universe PUT round-trips
through GET, and a genuinely-unknown id still 422s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0a161d430533845b0c4c322047701b93aea49a06)
2026-06-02 14:15:31 +02:00

232 lines
9.0 KiB
TypeScript

/**
* Insights tile layout — persisted in `User.insightsLayoutJson`.
*
* Single source of truth for which insights pills show on `/insights`,
* what order they render in, and what the default layout is for new
* users. Null / missing column = default layout below; the GET endpoint
* never lazy-writes a row, so the column only carries data once the
* user has explicitly saved a layout.
*
* Mirrors `dashboard-layout.ts` for the dashboard widget surface — same
* resolver / serializer semantics, same auto-merge of new tile IDs
* introduced in later releases. Keeping the two modules structurally
* identical means the iOS client + the Settings UI can share rendering
* code paths against either surface.
*/
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
/**
* Every insights tile the layout knows about. The mother-page overview
* is keyed `"overview"`; every other id is a routed sub-page slug under
* `/insights/<slug>`. The order of entries here is irrelevant —
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
* Exported so the API Zod `tileIdEnum` can be built from a single
* source of truth.
*
* v1.8.7.1 — the tile-id universe is derived FROM `SUB_PAGE_SLUGS`
* (`src/lib/insights/sub-page-metric.ts`), the same record the routed
* tab strip and the per-metric sub-pages enumerate. The two surfaces
* therefore stay in lockstep by construction: adding a sub-page slug
* automatically widens the layout enum, and the iOS client can persist
* a layout covering the full metric set (blood-pressure … skin-
* temperature) rather than tripping a 422 on the ~25 slugs the layout
* previously did not know about. `"overview"` is layout-only — it is
* the mother page, not a sub-page slug — so it is prepended explicitly.
*
* v1.8.0 — the canonical ids are English, matching the routed slugs
* (`/insights/<slug>`) and the naming-convention ADR
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
* through v1.7.x and the iOS layout-sync contract
* (`GET/PUT /api/insights/layout`) speaks them, so the layout endpoint
* keeps ACCEPTING the legacy German ids and NORMALISES them to the
* canonical English ones on both read and write — see
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
* No client breaks; iOS migrates to the English ids at its own pace.
*/
export const INSIGHTS_TILE_IDS = ["overview", ...SUB_PAGE_SLUGS] as const;
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
/**
* Legacy (≤ v1.7.x) German tile ids mapped to their canonical English
* replacement. The layout endpoint accepts these as input so the iOS
* client's stored layouts keep round-tripping after the v1.8.0 rename;
* every write path normalises them away before persisting (see
* `normalizeInsightsTileId`). `bmi`, `hrv`, `workouts`, and `overview`
* were already language-neutral and need no alias.
*/
export const LEGACY_INSIGHTS_TILE_ID_ALIASES: Record<string, InsightsTileId> = {
blutdruck: "blood-pressure",
puls: "pulse",
sauerstoff: "oxygen",
koerpertemperatur: "body-temperature",
gewicht: "weight",
"aktive-energie": "active-energy",
schlaf: "sleep",
ruhepuls: "resting-pulse",
stimmung: "mood",
medikamente: "medications",
};
/**
* Every id the layout endpoint accepts on input — canonical English
* plus the legacy German aliases. The Zod `tileIdEnum` is built from
* this union so a PUT carrying old ids passes validation rather than
* tripping a 422; `normalizeInsightsTileId` collapses the union back to
* the canonical id before anything persists.
*/
export const ACCEPTED_INSIGHTS_TILE_IDS = [
...INSIGHTS_TILE_IDS,
...Object.keys(LEGACY_INSIGHTS_TILE_ID_ALIASES),
] as const;
/**
* Collapse a legacy German tile id onto its canonical English id.
* Canonical ids (and unknown ids) pass through unchanged — the
* resolver's known-id filter drops anything truly unrecognised.
*/
export function normalizeInsightsTileId(id: string): string {
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
}
export interface InsightsTileConfig {
id: InsightsTileId;
visible: boolean;
order: number;
}
export interface InsightsLayout {
version: number;
tiles: InsightsTileConfig[];
}
const INSIGHTS_LAYOUT_VERSION = 1;
/**
* Default layout mirrors the routed tab-strip order — vitals first,
* then body composition, activity, sleep, cardiovascular, mood, then
* events. Core surfaces are default-visible; the long-tail HealthKit
* pills (oxygen saturation, body temperature, active energy, sleep,
* resting HR, HRV) stay default-invisible and the user opts in once
* the matching data starts to flow. Adding a new tile lands as an
* additional `{ id, visible: false, order: N }` row here and the
* resolver auto-merges it onto existing users' saved layouts.
*/
// Core surfaces that ship default-visible for a fresh account. Every
// other tile (the long-tail HealthKit + body-composition + mobility +
// audio pills) stays default-invisible and the user opts in once the
// matching data starts to flow. Keeping this as an explicit set means
// the default-visibility decision is auditable in one place while the
// tile-id universe itself derives from `SUB_PAGE_SLUGS`.
const DEFAULT_VISIBLE_TILE_IDS = new Set<InsightsTileId>([
"overview",
"blood-pressure",
"pulse",
"weight",
"bmi",
"workouts",
"mood",
"medications",
]);
export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
version: INSIGHTS_LAYOUT_VERSION,
// Order follows `INSIGHTS_TILE_IDS` (overview first, then the
// `SUB_PAGE_SLUGS` order, which itself tracks the MeasurementCategory
// overlay: vitals → body → activity → sleep → cardiovascular → mood →
// events). The dense 0-based `order` mirrors the routed tab strip.
tiles: INSIGHTS_TILE_IDS.map((id, order) => ({
id,
visible: DEFAULT_VISIBLE_TILE_IDS.has(id),
order,
})),
};
export function resolveInsightsLayout(raw: unknown): InsightsLayout {
if (!raw || typeof raw !== "object") return DEFAULT_INSIGHTS_LAYOUT;
const candidate = raw as Partial<InsightsLayout>;
if (
typeof candidate.version !== "number" ||
!Array.isArray(candidate.tiles)
) {
return DEFAULT_INSIGHTS_LAYOUT;
}
// v1.8.0 — normalise legacy German tile ids onto their canonical
// English replacement BEFORE the known-id filter so a layout persisted
// by a ≤ v1.7.x client (or the iOS app still speaking the old ids)
// survives the read instead of being silently dropped as "unknown".
// Dedupe afterwards: a layout carrying both the legacy and the
// canonical id (e.g. a partial migration) keeps the first occurrence
// so the round-trip stays idempotent.
const knownIds = new Set<string>(INSIGHTS_TILE_IDS);
const seen = new Set<string>();
const filtered: InsightsTileConfig[] = candidate.tiles
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t): t is InsightsTileConfig => {
if (!knownIds.has(t.id) || seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
// Merge with defaults so new tiles introduced in later versions show
// up automatically (invisible by default, the user opts in).
const savedIds = new Set(filtered.map((t) => t.id));
const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter(
(t) => !savedIds.has(t.id),
);
const maxOrder = Math.max(0, ...filtered.map((t) => t.order));
const appended = missing.map((t, i) => ({
...t,
visible: false, // default-invisible on auto-upgrade
order: maxOrder + 1 + i,
}));
return {
version: INSIGHTS_LAYOUT_VERSION,
tiles: [...filtered, ...appended].sort((a, b) => a.order - b.order),
};
}
/**
* Input shape for {@link serializeInsightsLayout}. Looser than
* {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT
* handler can hand a Zod-parsed body that still carries legacy German
* aliases — `serializeInsightsLayout` normalises them onto canonical
* {@link InsightsTileId} values on the way through.
*/
export interface InsightsLayoutInput {
version: number;
tiles: Array<{ id: string; visible: boolean; order: number }>;
}
export function serializeInsightsLayout(
layout: InsightsLayoutInput,
): InsightsLayout {
// v1.8.0 — normalise legacy German tile ids onto canonical English
// before persisting so the stored blob is always canonical even when
// the PUT body carried the old ids (the Zod enum accepts both via
// `ACCEPTED_INSIGHTS_TILE_IDS`). Dedupe so a body sending both the
// legacy and canonical id for one tile collapses to a single entry,
// keeping the dense 0-based order contiguous.
const seen = new Set<string>();
return {
version: INSIGHTS_LAYOUT_VERSION,
tiles: layout.tiles
.slice()
.sort((a, b) => a.order - b.order)
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
})
.map((t, i) => ({
id: t.id as InsightsTileId,
visible: t.visible,
order: i, // normalize to 0-based dense order
})),
};
}