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")} +

+
+
+ {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", +};