feat(dashboard): make the native-only widgets configurable from the web

BMI renders on the native dashboard and was configurable from nowhere.
Two individually-correct decisions produced it: `bmi` is a catalogue id
with no web render path, and the web layout editor filters those out
because a toggle over something this page cannot draw would be a silent
no-op. The widgets PUT accepts and persists them all the same, and the
native client writes them, so an account ended up holding rows it never
set and could not change from any surface.

The sortable list stays exactly as it was. Below it, the ids the web
does not draw but the layout carries get their own labelled group with
the same two flags and no reordering — placement belongs to the client
that draws them. An account with no native client has nothing there and
sees nothing. The module gate applies as it does above, fail-open.

Refs #581
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 14:47:56 +02:00
1 parent e4a4c996b7
commit 7997e2c61f
9 files changed
+347 -36

No files matched your search

+2
View File
@@ -468,6 +468,8 @@
"moveDown": "Nach unten",
"layoutTileColumn": "Kachel",
"layoutChartColumn": "Chart",
"layoutNativeOnlyTitle": "In der mobilen App sichtbar",
"layoutNativeOnlyHint": "Diese Widgets zeichnet die mobile App, nicht diese Seite. Sie liegen im selben Layout — was du hier ausschaltest, ist auch dort aus.",
"dragHandle": "Zum Verschieben ziehen",
"dragHandleHint": "Pfeiltasten Hoch/Runter, oder Leertaste plus Pfeiltasten am fokussierten Griff.",
"emptyTitle": "Dein Dashboard ist leer",
+2
View File
@@ -468,6 +468,8 @@
"moveDown": "Move down",
"layoutTileColumn": "Tile",
"layoutChartColumn": "Chart",
"layoutNativeOnlyTitle": "Shown in the mobile app",
"layoutNativeOnlyHint": "The mobile app draws these; this page does not. They are stored in the same layout, so switching one off here turns it off there as well.",
"dragHandle": "Drag to reorder",
"dragHandleHint": "Drag the handle to reorder this widget. You can also use the Up and Down arrow buttons, or focus the handle and press Space and the arrow keys.",
"emptyTitle": "Your dashboard is empty",
+2
View File
@@ -468,6 +468,8 @@
"moveDown": "Bajar",
"layoutTileColumn": "Tarjeta",
"layoutChartColumn": "Gráfico",
"layoutNativeOnlyTitle": "Visible en la app móvil",
"layoutNativeOnlyHint": "Estos los dibuja la app móvil, no esta página. Se guardan en el mismo diseño, así que apagar uno aquí también lo apaga allí.",
"dragHandle": "Arrastra para reordenar",
"dragHandleHint": "Arrastra el manejador para reordenar este widget. También puedes usar los botones de flecha Arriba y Abajo, o enfocar el manejador y pulsar Espacio más las flechas.",
"emptyTitle": "Tu panel está vacío",
+2
View File
@@ -468,6 +468,8 @@
"moveDown": "Descendre",
"layoutTileColumn": "Tuile",
"layoutChartColumn": "Graphique",
"layoutNativeOnlyTitle": "Affiché dans l'application mobile",
"layoutNativeOnlyHint": "Ces widgets sont dessinés par l'application mobile, pas par cette page. Ils sont enregistrés dans la même disposition : les désactiver ici les désactive aussi là-bas.",
"dragHandle": "Glisser pour réordonner",
"dragHandleHint": "Glisse la poignée pour réordonner ce widget. Tu peux aussi utiliser les flèches Haut et Bas, ou cibler la poignée puis appuyer sur Espace plus les flèches.",
"emptyTitle": "Ton tableau de bord est vide",
+2
View File
@@ -468,6 +468,8 @@
"moveDown": "Sposta giù",
"layoutTileColumn": "Riquadro",
"layoutChartColumn": "Grafico",
"layoutNativeOnlyTitle": "Visibile nell'app mobile",
"layoutNativeOnlyHint": "Questi li disegna l'app mobile, non questa pagina. Sono salvati nello stesso layout, quindi disattivarne uno qui lo disattiva anche lì.",
"dragHandle": "Trascina per riordinare",
"dragHandleHint": "Trascina la maniglia per riordinare questo widget. Puoi anche usare i pulsanti freccia Su e Giù, oppure mettere a fuoco la maniglia e premere Spazio e i tasti freccia.",
"emptyTitle": "La tua dashboard è vuota",
+2
View File
@@ -468,6 +468,8 @@
"moveDown": "W dół",
"layoutTileColumn": "Kafelek",
"layoutChartColumn": "Wykres",
"layoutNativeOnlyTitle": "Widoczne w aplikacji mobilnej",
"layoutNativeOnlyHint": "Te widżety rysuje aplikacja mobilna, nie ta strona. Są zapisane w tym samym układzie, więc wyłączenie tutaj wyłącza je również tam.",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"dragHandleHint": "Strzałki Góra/Dół, lub Spacja plus strzałki na zaznaczonym uchwycie.",
"emptyTitle": "Twój panel jest pusty",
@@ -12,6 +12,7 @@ import {
widgetWritesBusy,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id
// (`DASHBOARD_WIDGET_IDS`) MINUS the `IOS_PIN_ONLY_WIDGET_IDS` (writable so
@@ -113,10 +114,14 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? [];
// One tile + one chart switch per WEB-renderable widget (the
// iOS-pin-only ids are filtered out — they have no web render path).
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// One tile + one chart switch per row: the WEB-renderable widgets in
// the sortable list, plus the native-only rows below it (issue #581 —
// the iOS-pin-only ids the default layout carries).
const nativeRows = (html.match(/data-slot="native-widget-row"/g) ?? [])
.length;
expect(nativeRows).toBe(IOS_PIN_ONLY_WIDGET_IDS.length);
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
});
});
@@ -236,14 +241,17 @@ describe("reorderWidgets — pure mutation contract", () => {
});
/**
* v1.7.0 W1 — the stored layout now round-trips the 11 iOS-only widget
* ids. The web Settings list has no tile/chart surface for them, so the
* render must SKIP an id with no web component rather than paint an
* unlabelled row with dead toggles.
* v1.7.0 W1 — the stored layout round-trips the iOS-only widget ids. The
* web tile/chart LIST still has no surface for them (no drag handle, no
* reorder, no web render path), so the sortable list must skip them.
*
* issue #581 — but skipping them everywhere left the account holding rows
* it never set and could not change from any surface, so they now appear in
* a separate, clearly-labelled group with their visibility flags.
*/
describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
it("renders only web-known rows when the layout carries iOS-only ids", () => {
// Inject a layout = the 16 web defaults + the 11 iOS-only ids.
describe("<DashboardLayoutSection> — iOS-only ids stay out of the web list (v1.7.0)", () => {
it("keeps iOS-only ids out of the sortable web list", () => {
// Inject a layout = the web defaults + every catalogue-only iOS id.
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
@@ -259,13 +267,13 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// One row (= one tile switch) per WEB-renderable widget — the iOS-only
// ids (and the iOS-pin-only writable ids) are skipped, not rendered as
// raw-id rows.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// The sortable list is exactly the web-renderable widgets: one drag
// handle each, and no more.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// No iOS-only raw id leaks into the markup as a row label.
// No iOS-only raw id leaks into the markup as a row label — every one
// of them resolves through a translation key.
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(html).not.toContain(`>${iosId}<`);
}
@@ -283,31 +291,134 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// No web-known rows → zero switches, but the section still renders.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(0);
// No web-renderable rows → no sortable list at all, but the section
// still renders and every id lands in the native-only group.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(0);
const nativeRows = html.match(/data-slot="native-widget-row"/g) ?? [];
expect(nativeRows).toHaveLength(DASHBOARD_IOS_ONLY_WIDGET_IDS.length);
expect(html).toContain("dashboard-layout");
});
});
/**
* issue #581 — BMI rendered on the native dashboard and was configurable
* from nowhere: the widgets PUT accepts and persists it, the native client
* writes it, and the web Settings list filtered it out because the web has
* no render path for it. The account was left holding
* `{"id":"bmi","visible":false,…}` — a value it never set and could not
* change. The native-only group is where those flags become reachable.
*/
describe("<DashboardLayoutSection> — native-only widget group (issue #581)", () => {
const NATIVE_ONLY_IDS = [
...IOS_PIN_ONLY_WIDGET_IDS,
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
];
it("offers a tile and a chart switch for BMI when the layout carries it", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{
id: "bmi",
visible: false,
tileVisible: true,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length,
},
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-widget-id="bmi"');
expect(html).toContain('aria-label="BMI — Tile"');
expect(html).toContain('aria-label="BMI — Chart"');
// Reflects the stored flags rather than inventing a default.
const row = html.match(
/data-widget-id="bmi"[\s\S]*?(?=data-slot="native-widget-row"|$)/,
)![0];
expect(row).toContain('data-slot="widget-tile-switch"');
expect(row).toContain('data-slot="widget-chart-switch"');
// ...and it is NOT in the sortable web list (no drag handle for it).
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
it("labels the group and says which client draws these widgets", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="native-only-widgets"');
expect(html).toContain("Shown in the mobile app");
expect(html).toContain("The mobile app draws these");
});
it("shows nothing when the layout holds no native-only id", () => {
// The stock web default layout carries the pin-only writable ids but
// no catalogue-only ones; strip both so a pure web account is modelled.
const nativeOnly = new Set<string>(NATIVE_ONLY_IDS);
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => !nativeOnly.has(w.id),
),
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).not.toContain('data-slot="native-only-widgets"');
expect(html).not.toContain("Shown in the mobile app");
});
it("every native-only id has a label key, so no row can render raw", () => {
for (const id of NATIVE_ONLY_IDS) {
expect(
NATIVE_ONLY_WIDGET_LABEL_KEYS[id],
`${id} has no label key — its row would render its raw id`,
).toBeTruthy();
}
});
it("hides a native-only row whose owning module is off", () => {
authState.modules = { recovery: false };
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// `cardioRecovery` belongs to the recovery module and drops out; BMI
// belongs to none and stays.
expect(html).not.toContain('data-widget-id="cardioRecovery"');
expect(html).toContain('data-widget-id="bmi"');
authState.modules = undefined;
});
});
/**
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
* iOS pin PUT validates them) but have NO web render path, so the web
* Settings list must NOT offer a dead toggle for them.
*/
describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2)", () => {
it("renders one fewer row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all 24 writable widgets incl. the 8 pin-only.
it("renders one fewer sortable row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all writable widgets incl. the pin-only ones.
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Sanity: WEB_RENDERABLE_ROW_COUNT == writable − pin-only.
expect(WEB_RENDERABLE_ROW_COUNT).toBe(
DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length,
);
});
it("does not paint a row whose aria-label matches an iOS-pin-only widget label", () => {
it("paints the pin-only labels ONLY in the native-only group", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
@@ -325,10 +436,17 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
// The sortable web list must not offer them (issue #581 note: the
// native-only group below it does, which is where the markup match
// now comes from — so anchor on the sortable rows themselves).
const sortableList = html.slice(
0,
html.indexOf('data-slot="native-only-widgets"'),
);
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
// The widget label drives the switch aria-label; if the row were
// rendered the localised label would show up in the markup.
expect(html).not.toContain(`${pinOnlyLabels[id]} — `);
expect(sortableList).not.toContain(`${pinOnlyLabels[id]} — `);
// ...and each is reachable exactly once, in the native-only group.
expect(html).toContain(`data-widget-id="${id}"`);
}
});
});
@@ -354,9 +472,9 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The achievements row (and its switches) are gone…
expect(html).not.toContain(ACHIEVEMENTS_ARIA);
// …and exactly one fewer web-renderable row renders.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1);
// …and exactly one fewer sortable row renders.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1);
});
it("keeps an enabled-module widget and core (no-module) widgets shown", () => {
@@ -371,8 +489,8 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
it("shows every row when the module map is absent (fail-open)", () => {
authState.modules = undefined;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
expect(html).toContain(ACHIEVEMENTS_ARIA);
});
});
@@ -42,7 +42,9 @@ import {
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
type DashboardWidgetCatalogueId,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
@@ -61,7 +63,10 @@ import {
} from "@/components/ui/select";
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
import {
NATIVE_ONLY_WIDGET_LABEL_KEYS,
WIDGET_MODULE_BY_ID,
} from "@/lib/dashboard/widget-modules";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -348,7 +353,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
},
});
function toggle(widgetId: DashboardWidgetId, visible: boolean) {
function toggle(widgetId: DashboardWidgetCatalogueId, visible: boolean) {
if (!layout) return;
setDraft({
...layout,
@@ -365,7 +370,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
* coarse: they wanted a chart visible without the tile (for metrics they
* tracks without wanting the at-a-glance number) or vice versa.
*/
function toggleTile(widgetId: DashboardWidgetId, tileVisible: boolean) {
function toggleTile(
widgetId: DashboardWidgetCatalogueId,
tileVisible: boolean,
) {
if (!layout) return;
setDraft({
...layout,
@@ -911,6 +919,74 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{t("dashboard.dragHandleHint")}
</p>
)}
{/* issue #581 — widgets the mobile app draws and this page does
not. Filtering them out of the list above is right (a toggle
over something the web cannot draw would be a silent no-op),
but the native client materialises them into the SAME stored
layout, so the account ended up holding rows it never set and
could not change from anywhere. They get their own labelled
group: the same two flags, no reordering (placement is the
native client's), and only for ids the layout actually holds —
an account with no native client has nothing to configure here
and sees nothing. */}
{(() => {
const nativeOnlyIds = new Set<string>([
...IOS_PIN_ONLY_WIDGET_IDS,
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
]);
const nativeWidgets = layout.widgets
.filter((w) => nativeOnlyIds.has(w.id))
// Same fail-open module gate as the web list: only an explicit
// `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
.sort((a, b) => a.order - b.order);
if (nativeWidgets.length === 0) return null;
return (
<div
data-slot="native-only-widgets"
className="border-border/60 mt-6 space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("dashboard.layoutNativeOnlyTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.layoutNativeOnlyHint")}
</p>
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
<span className="flex-1" aria-hidden="true" />
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
</span>
<span className="w-12 text-center">
{t("dashboard.layoutChartColumn")}
</span>
</div>
{nativeWidgets.map((widget) => {
const label = t(
NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
);
return (
<StaticWidgetRow
key={widget.id}
widget={widget}
disabled={saveMutation.isPending}
labels={{
tileColumn: t("dashboard.layoutTileColumn"),
chartColumn: t("dashboard.layoutChartColumn"),
widgetLabel: label,
}}
onToggleTile={toggleTile}
onToggleChart={toggle}
/>
);
})}
</div>
);
})()}
</div>
)}
@@ -1116,6 +1192,66 @@ function SortableWidgetRow({
);
}
/**
* issue #581 — one row in the native-only group: the same two switches as a
* widget row above, without the drag handle or the arrow buttons. Placement
* of these widgets belongs to the client that draws them, so the web offers
* visibility and nothing it cannot honour.
*/
function StaticWidgetRow({
widget,
disabled,
labels,
onToggleTile,
onToggleChart,
}: {
widget: {
id: DashboardWidgetCatalogueId;
visible: boolean;
tileVisible?: boolean;
order: number;
};
disabled: boolean;
labels: { tileColumn: string; chartColumn: string; widgetLabel: string };
onToggleTile: (id: DashboardWidgetCatalogueId, value: boolean) => void;
onToggleChart: (id: DashboardWidgetCatalogueId, value: boolean) => void;
}) {
const tileChecked =
typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
return (
<div
data-slot="native-widget-row"
data-widget-id={widget.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
</div>
</div>
);
}
/**
* v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the
* `SortableWidgetRow` interaction exactly (drag handle + arrow buttons +
+45
View File
@@ -154,3 +154,48 @@ export function gateMetricCardsByModules<T extends { kind: string }>(
return !type || !dropped.has(type);
});
}
/**
* Label key for every catalogue widget the WEB does not render.
*
* These ids round-trip through `/api/dashboard/widgets` and are drawn by the
* native client, but `src/app/page.tsx` has no render path for them, so the
* web Settings list filters them out of its tile/chart rows. That filtering
* is right — a toggle over a widget this page cannot draw would be a silent
* no-op — but paired with the native client materialising them into the
* stored layout it produced a widget nobody could turn off from anywhere
* (issue #581): the row `{"id":"bmi", …}` was a value the account never set
* and could not change.
*
* So the Settings page surfaces them in a clearly-labelled second group whose
* copy says these are drawn by the mobile app. Membership is derived from the
* two id constants in `@/lib/dashboard-layout` rather than repeated here; this
* map only answers "what do we call it", and a guard test asserts it covers
* every native-only id so a new one cannot arrive unlabelled.
*/
export const NATIVE_ONLY_WIDGET_LABEL_KEYS: Record<string, string> = {
// Writable ids (in `DASHBOARD_WIDGET_IDS`) with no web render path.
cardioRecovery: "measurements.typeCardioRecovery",
sixMinuteWalk: "measurements.typeSixMinuteWalkDistance",
stairAscentSpeed: "measurements.typeStairAscentSpeed",
stairDescentSpeed: "measurements.typeStairDescentSpeed",
breathingDisturbances: "measurements.typeBreathingDisturbances",
falls: "measurements.typeFallCount",
walkingSteadiness: "measurements.typeWalkingSteadiness",
// Catalogue-only ids the native client materialises in its own layout.
restingHeartRate: "measurements.typeRestingHeartRate",
walkingSpeed: "measurements.typeWalkingSpeed",
walkingAsymmetry: "measurements.typeWalkingAsymmetry",
walkingStepLength: "measurements.typeWalkingStepLength",
// BMI is derived from weight + the profile height; the dashboard metric
// title is the name every other surface already gives it.
bmi: "dashboard.metric.title.bmi",
bodyTemperature: "measurements.typeBodyTemperature",
walkingDoubleSupport: "measurements.typeWalkingDoubleSupport",
audioExposureEnvironment: "measurements.typeAudioExposureEnv",
audioExposureHeadphone: "measurements.typeAudioExposureHeadphone",
gripStrength: "measurements.typeGripStrength",
painNRS: "measurements.typePainNrs",
waistCircumference: "measurements.typeWaistCircumference",
waistToHeight: "measurements.typeWaistToHeight",
};