diff --git a/messages/de.json b/messages/de.json
index 18d6ef5f3..688822e81 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 28e7a704f..1b5881185 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index cecad4c0e..e08378bc0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index bdce1e8ec..e4ba24b14 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 20ff430fd..28c3b5ec5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index b0b01785a..0c1624d07 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx
index da6a8362a..4c72b5014 100644
--- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx
+++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx
@@ -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(" — tile + chart split", () => {
const html = render();
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(" — 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(" — 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(" — iOS-only id skip (v1.7.0)", () => {
const html = render();
- // 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(" — iOS-only id skip (v1.7.0)", () => {
};
const html = render();
- // 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(" — 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();
+
+ 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();
+ 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(NATIVE_ONLY_IDS);
+ queryState.layout = {
+ ...DEFAULT_DASHBOARD_LAYOUT,
+ widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
+ (w) => !nativeOnly.has(w.id),
+ ),
+ };
+ const html = render();
+ 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();
+ // `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(" — 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();
- 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();
// 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(" — 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(" — disabled-module widget toggles", () => {
const html = render();
// 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(" — disabled-module widget toggles", () => {
it("shows every row when the module map is absent (fail-open)", () => {
authState.modules = undefined;
const html = render();
- 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);
});
});
diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx
index ff131dfd5..d3e2907d1 100644
--- a/src/components/settings/dashboard-layout-section.tsx
+++ b/src/components/settings/dashboard-layout-section.tsx
@@ -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")}
)}
+
+ {/* 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([
+ ...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 (
+
+
+ {t("dashboard.layoutNativeOnlyTitle")}
+
+
+ {t("dashboard.layoutNativeOnlyHint")}
+
+
+
+
+ {t("dashboard.layoutTileColumn")}
+
+
+ {t("dashboard.layoutChartColumn")}
+
+
+ {nativeWidgets.map((widget) => {
+ const label = t(
+ NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
+ );
+ return (
+
+ );
+ })}
+
+ );
+ })()}
)}
@@ -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 (
+
+
+ {labels.widgetLabel}
+
+
+ onToggleTile(widget.id, v)}
+ aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
+ disabled={disabled}
+ data-slot="widget-tile-switch"
+ />
+
+
+ onToggleChart(widget.id, v)}
+ aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
+ disabled={disabled}
+ data-slot="widget-chart-switch"
+ />
+
+
+ );
+}
+
/**
* v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the
* `SortableWidgetRow` interaction exactly (drag handle + arrow buttons +
diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts
index 3bddac9d8..793d33c91 100644
--- a/src/lib/dashboard/widget-modules.ts
+++ b/src/lib/dashboard/widget-modules.ts
@@ -154,3 +154,48 @@ export function gateMetricCardsByModules(
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 = {
+ // 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",
+};