From 885efaafd9a77f86a9e24eff3a1bb18bea45ab05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:21:38 +0200 Subject: [PATCH] fix(dashboard): open the body fat page from the body fat tile The body fat tile was the one strip tile that led nowhere. Its href map said there was no dedicated sub-page, which was true: body fat percentage was tiled and charted on the dashboard but never got the Insights page every body-composition sibling has. Add /insights/body-fat on the generic metric-page scaffold (body group, tab-strip pill, seven locales, manual-entry empty state) and link the tile to it. Move the tile href map out of the page into tile-destinations.ts, keyed by the tile-capable widget id and typed against the sub-page slug union, so a missing or non-existent destination fails to compile. The link passes the destination's insights-page surface gate, so it never points at a page a switched-off module hides. A guard derives the tile set from TILE_CAPABLE_WIDGET_IDS and from the ids the dashboard actually pushes into its strip, and fails when a tile's metric has a sub-page the tile does not open, when a destination has no route, or when a tile links to a page owned by another module. Tiles that deliberately lead nowhere need a written reason; there are none today. The reverse-lookup test pinned BODY_FAT as having no page; it now resolves to body-fat. Refs #1084 --- docs/api/openapi.yaml | 9 +- messages/de.json | 12 + messages/en.json | 12 + messages/es.json | 12 + messages/fr.json | 12 + messages/it.json | 12 + messages/ko.json | 12 + messages/pl.json | 12 + src/app/insights/body-fat/page.tsx | 31 +++ src/app/page-client.tsx | 44 +--- .../__tests__/tile-destinations.test.ts | 207 ++++++++++++++++++ src/components/dashboard/dashboard-gates.ts | 17 +- src/components/dashboard/tile-destinations.ts | 81 +++++++ .../insights/insights-tab-strip.tsx | 1 + .../__tests__/sub-page-metric-reverse.test.ts | 4 +- src/lib/insights/metric-availability.ts | 3 + src/lib/insights/sub-page-metric.ts | 7 +- 17 files changed, 440 insertions(+), 48 deletions(-) create mode 100644 src/app/insights/body-fat/page.tsx create mode 100644 src/components/dashboard/__tests__/tile-destinations.test.ts create mode 100644 src/components/dashboard/tile-destinations.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4a1bd8893..6539b387b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25902,7 +25902,7 @@ components: - order tiles: minItems: 1 - maxItems: 62 + maxItems: 63 type: array items: type: object @@ -25919,6 +25919,7 @@ components: - pain - weight - bmi + - body-fat - body-water - bone-mass - fat-free-mass @@ -31264,7 +31265,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 62 + maxItems: 63 type: array items: type: object @@ -31281,6 +31282,7 @@ components: - pain - weight - bmi + - body-fat - body-water - bone-mass - fat-free-mass @@ -51976,7 +51978,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 62 + maxItems: 63 type: array items: type: object @@ -51993,6 +51995,7 @@ components: - pain - weight - bmi + - body-fat - body-water - bone-mass - fat-free-mass diff --git a/messages/de.json b/messages/de.json index 66cc4a50a..eafb5e548 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3157,6 +3157,7 @@ "boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.", "fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.", "fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko (WHO 2024).", + "bodyFatBody": "Der Körperfettanteil ist der Teil deines Gewichts, der aus Fett besteht. Gesunde Bereiche hängen von Geschlecht und Alter ab, und Waagen schätzen den Wert per Bioimpedanz, deshalb sagt der Verlauf über mehrere Wochen mehr als eine einzelne Messung.", "muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.", "visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.", "leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.", @@ -3575,7 +3576,18 @@ "description": "Fettmassewerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert." } }, + "bodyFat": { + "title": "Körperfett", + "description": "Dein Körperfettanteil im Zeitverlauf.", + "chartTitle": "Körperfett", + "emptyState": { + "title": "Noch keine Körperfettdaten", + "description": "Körperfettwerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert oder du einen Wert von Hand erfasst.", + "cta": "Körperfett erfassen" + } + }, "navFatMass": "Fettmasse", + "navBodyFat": "Körperfett", "muscleMass": { "title": "Muskelmasse", "description": "Deine Muskelmasse im Zeitverlauf, in Kilogramm.", diff --git a/messages/en.json b/messages/en.json index 8bfe26d62..ececfe100 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3157,6 +3157,7 @@ "boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.", "fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.", "fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk (WHO 2024).", + "bodyFatBody": "Body fat percentage is the share of your weight that is fat. Healthy ranges depend on sex and age, and home scales estimate it from bioimpedance, so the trend over several weeks says more than a single reading.", "muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.", "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.", "leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.", @@ -3575,7 +3576,18 @@ "description": "Fat-mass readings appear here once a connected body-composition scale syncs." } }, + "bodyFat": { + "title": "Body Fat", + "description": "Your body fat percentage over time.", + "chartTitle": "Body fat", + "emptyState": { + "title": "No body-fat data yet", + "description": "Body-fat readings appear here once a body-composition scale syncs, or when you log one by hand.", + "cta": "Log body fat" + } + }, "navFatMass": "Fat Mass", + "navBodyFat": "Body Fat", "muscleMass": { "title": "Muscle Mass", "description": "Your muscle mass over time, in kilograms.", diff --git a/messages/es.json b/messages/es.json index a3e109389..1d70d0463 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3157,6 +3157,7 @@ "boneMassBody": "La masa ósea es el peso estimado del mineral de tu esqueleto. Las estimaciones de la báscula son aproximadas, pero una tendencia estable o al alza favorece la fortaleza ósea a largo plazo.", "fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.", "fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo (WHO 2024).", + "bodyFatBody": "El porcentaje de grasa corporal es la parte de tu peso que es grasa. Los rangos saludables dependen del sexo y la edad, y las básculas lo estiman por bioimpedancia, así que la tendencia de varias semanas dice más que una sola lectura.", "muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.", "visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2, por lo que cuanto más baja, mejor.", "leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.", @@ -3575,7 +3576,18 @@ "description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza." } }, + "bodyFat": { + "title": "Grasa corporal", + "description": "Tu porcentaje de grasa corporal en el tiempo.", + "chartTitle": "Grasa corporal", + "emptyState": { + "title": "Aún no hay datos de grasa corporal", + "description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza o cuando registras una a mano.", + "cta": "Registrar grasa corporal" + } + }, "navFatMass": "Masa grasa", + "navBodyFat": "Grasa corporal", "muscleMass": { "title": "Masa muscular", "description": "Tu masa muscular en el tiempo, en kilogramos.", diff --git a/messages/fr.json b/messages/fr.json index d9e6a5017..5b87d28fc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3157,6 +3157,7 @@ "boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.", "fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.", "fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme (WHO 2024).", + "bodyFatBody": "Le taux de masse grasse est la part de votre poids constituée de graisse. Les plages saines dépendent du sexe et de l'âge, et les balances l'estiment par bio-impédance : la tendance sur plusieurs semaines en dit plus qu'une mesure isolée.", "muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.", "visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.", "leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.", @@ -3575,7 +3576,18 @@ "description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise." } }, + "bodyFat": { + "title": "Taux de masse grasse", + "description": "Votre taux de masse grasse au fil du temps.", + "chartTitle": "Taux de masse grasse", + "emptyState": { + "title": "Aucune donnée de taux de masse grasse", + "description": "Les relevés apparaissent ici dès qu'une balance à impédance se synchronise ou que vous en saisissez un à la main.", + "cta": "Enregistrer le taux de masse grasse" + } + }, "navFatMass": "Masse grasse", + "navBodyFat": "Taux de masse grasse", "muscleMass": { "title": "Masse musculaire", "description": "Votre masse musculaire au fil du temps, en kilogrammes.", diff --git a/messages/it.json b/messages/it.json index 639c0a7a6..035aec921 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3157,6 +3157,7 @@ "boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.", "fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.", "fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine (WHO 2024).", + "bodyFatBody": "La percentuale di grasso corporeo è la parte del tuo peso costituita da grasso. Gli intervalli sani dipendono da sesso ed età, e le bilance la stimano con la bioimpedenza, quindi l'andamento su più settimane dice più di una singola misura.", "muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.", "visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.", "leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.", @@ -3575,7 +3576,18 @@ "description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza." } }, + "bodyFat": { + "title": "Grasso corporeo", + "description": "La tua percentuale di grasso corporeo nel tempo.", + "chartTitle": "Grasso corporeo", + "emptyState": { + "title": "Nessun dato di grasso corporeo", + "description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza o quando ne registri una a mano.", + "cta": "Registra il grasso corporeo" + } + }, "navFatMass": "Massa grassa", + "navBodyFat": "Grasso corporeo", "muscleMass": { "title": "Massa muscolare", "description": "La tua massa muscolare nel tempo, in chilogrammi.", diff --git a/messages/ko.json b/messages/ko.json index bab8a4d71..54c70b0e6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3157,6 +3157,7 @@ "boneMassBody": "골량은 뼈에 들어 있는 무기질의 추정 무게예요. 체중계 기반 추정은 대략적이지만, 유지되거나 올라가는 추이는 장기적인 뼈 건강에 도움이 돼요.", "fatFreeMassBody": "제지방량은 몸에서 지방이 아닌 전부 — 근육·뼈·장기·수분이에요. 체중을 줄이면서도 이걸 유지하거나 늘리면 대체로 더 건강한 체성분을 뜻해요.", "fatMassBody": "체지방량은 몸속 지방의 총 무게예요. 지방은 호르몬과 에너지 저장에 어느 정도 꼭 필요하지만, 특히 복부에 너무 많으면 장기적인 건강 위험이 올라가요(WHO 2024).", + "bodyFatBody": "체지방률은 체중 중 지방이 차지하는 비율이에요. 건강한 범위는 성별과 나이에 따라 다르고, 체중계는 생체 전기 임피던스로 추정하므로 한 번의 측정값보다 몇 주간의 추이가 더 많은 걸 알려줘요.", "muscleMassBody": "근육량은 몸속 근육의 추정 무게예요. 근육이 많을수록 근력·대사·활동성에 도움이 되고, 나이가 들수록 유지하려면 적극적인 노력이 필요해요.", "visceralFatBody": "내장지방은 복부 깊숙이 장기 주변에 쌓이는 지방이에요. 피부 밑 지방과 달리 수치가 높으면 심장병과 2형 당뇨병에 밀접하게 연결되니 대체로 낮을수록 좋아요.", "leanBodyMassBody": "제지방 체중은 전체 체중에서 체지방을 뺀 값 — 근육·뼈·장기·수분을 합친 거예요. 체중과 함께 보면 변화가 근육에서 왔는지 지방에서 왔는지 알 수 있어요.", @@ -3575,7 +3576,18 @@ "description": "연결된 체성분 체중계가 동기화되면 체지방량 측정값이 여기에 나타나요." } }, + "bodyFat": { + "title": "체지방률", + "description": "시간에 따른 체지방률이에요.", + "chartTitle": "체지방률", + "emptyState": { + "title": "아직 체지방률 데이터가 없어요", + "description": "체성분 체중계가 동기화되거나 직접 기록하면 체지방률 측정값이 여기에 나타나요.", + "cta": "체지방률 기록" + } + }, "navFatMass": "체지방량", + "navBodyFat": "체지방률", "muscleMass": { "title": "근육량", "description": "시간에 따른 근육량이에요(kg).", diff --git a/messages/pl.json b/messages/pl.json index 1ecc73cc5..6ff598d6c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3157,6 +3157,7 @@ "boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.", "fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.", "fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).", + "bodyFatBody": "Procent tkanki tłuszczowej to część masy ciała, którą stanowi tłuszcz. Zdrowe zakresy zależą od płci i wieku, a wagi szacują go metodą bioimpedancji, dlatego trend z kilku tygodni mówi więcej niż pojedynczy pomiar.", "muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.", "visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.", "leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.", @@ -3575,7 +3576,18 @@ "description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej." } }, + "bodyFat": { + "title": "Tkanka tłuszczowa", + "description": "Twój procent tkanki tłuszczowej w czasie.", + "chartTitle": "Tkanka tłuszczowa", + "emptyState": { + "title": "Brak danych o tkance tłuszczowej", + "description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej lub po ręcznym zapisaniu wartości.", + "cta": "Zapisz tkankę tłuszczową" + } + }, "navFatMass": "Masa tłuszczowa", + "navBodyFat": "Tkanka tłuszczowa", "muscleMass": { "title": "Masa mięśniowa", "description": "Twoja masa mięśniowa w czasie, w kilogramach.", diff --git a/src/app/insights/body-fat/page.tsx b/src/app/insights/body-fat/page.tsx new file mode 100644 index 000000000..1b8c3eab6 --- /dev/null +++ b/src/app/insights/body-fat/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Percent } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * `/insights/body-fat`. + * + * BODY_FAT sub-page on the generic HealthKitMetricPage scaffold, like its + * body-composition siblings. The dashboard tile and chart for body fat + * predate the sub-pages; this is the page the tile opens. Readings arrive + * from a body-composition scale or by hand, so the empty state offers + * manual entry. + */ +export default function InsightsBodyFatPage() { + return ( + } + emptyStateCtaType="BODY_FAT" + /> + ); +} diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 5f47786d3..d4384b607 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -174,6 +174,7 @@ export { resolveConfiguredTileCount, resolveChartRowPlaceholderCount, } from "@/components/dashboard/dashboard-gates"; +import { dashboardTileHref } from "@/components/dashboard/tile-destinations"; import { resolveDashboardFirstPaintGate, resolveConfiguredTileCount, @@ -182,42 +183,6 @@ import { resolveGlucoseTiles, } from "@/components/dashboard/dashboard-gates"; -/** - * Dashboard metric tiles are no longer dead-ends: each tile links to its - * Insights detail page (bands, correlations, history). Keyed by the stable - * tile `id` used in the `trendCards` builder; glucose contexts share one - * blood-glucose destination via the `glucose-` prefix. Tiles with no - * dedicated sub-page (body fat %) stay un-linked rather than misroute. - */ -const TILE_INSIGHT_HREF: Record = { - weight: "/insights/weight", - "bp-sys": "/insights/blood-pressure", - "bp-dia": "/insights/blood-pressure", - bpInTarget: "/insights/blood-pressure", - pulse: "/insights/pulse", - mood: "/insights/mood", - sleep: "/insights/sleep", - steps: "/insights/steps", - vo2Max: "/insights/cardio-fitness", - // v1.28.52 — vitals + body-composition strip tiles link to their - // existing /insights detail pages (bands, history, correlations). - hrv: "/insights/hrv", - oxygenSaturation: "/insights/oxygen", - respiratoryRate: "/insights/respiratory-rate", - wristTemperature: "/insights/wrist-temperature", - muscleMass: "/insights/muscle-mass", - totalBodyWater: "/insights/body-water", - boneMass: "/insights/bone-mass", - // v1.29 — fluid intake links to the nutrients surface (hydration hero + - // quick-add), not a Measurement-backed detail page. - waterIntake: "/insights/nutrients", -}; - -function tileInsightHref(id: string): string | null { - if (id.startsWith("glucose-")) return "/insights/blood-glucose"; - return TILE_INSIGHT_HREF[id] ?? null; -} - export default function DashboardPageClient({ batchWindow: batchWindowProp, }: { @@ -2172,9 +2137,10 @@ export default function DashboardPageClient({ ); // A tile is no longer a dead-end: it links to its Insights - // detail page. The link fills the grid cell and paints a - // ring on hover / focus so the whole tile reads as tappable. - const href = tileInsightHref(entry.id); + // detail page (`tile-destinations.ts`, gated on the page's + // module). The link fills the grid cell and paints a ring + // on hover / focus so the whole tile reads as tappable. + const href = dashboardTileHref(entry.id, user?.modules); return href ? ( = { + weight: ["WEIGHT"], + bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + pulse: ["PULSE"], + bodyFat: ["BODY_FAT"], + mood: ["MOOD"], + sleep: ["SLEEP_DURATION"], + steps: ["ACTIVITY_STEPS"], + glucose: ["BLOOD_GLUCOSE"], + bpInTarget: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + vo2Max: ["VO2_MAX"], + hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"], + oxygenSaturation: ["OXYGEN_SATURATION"], + respiratoryRate: ["RESPIRATORY_RATE"], + wristTemperature: ["WRIST_TEMPERATURE"], + muscleMass: ["MUSCLE_MASS"], + totalBodyWater: ["TOTAL_BODY_WATER"], + boneMass: ["BONE_MASS"], + waterIntake: ["NUTRIENT_WATER"], +}; + +/** + * Tiles whose series is not a `MeasurementType` with a sub-page, but which + * still lead to a page that shows it. Each entry names its reason. + */ +const NON_MEASUREMENT_DESTINATION: Partial< + Record +> = { + waterIntake: { + slug: "nutrients", + reason: + "fluid intake is a NutrientIntakeDay total, charted on the nutrients page", + }, +}; + +/** + * Tiles that deliberately lead nowhere, with the reason. Empty today: every + * tile has a page. An entry here must be a tile whose destination is null. + */ +const NO_DESTINATION: Partial> = {}; + +/** Tile ids the dashboard pushes into its strip, read from the render code. */ +function renderedTileIds(): string[] { + const source = readFileSync( + path.join(ROOT, "src/app/page-client.tsx"), + "utf8", + ); + const ids: string[] = []; + const pattern = + /trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g; + for (const match of source.matchAll(pattern)) { + ids.push(match[1] ?? `${match[2]}*`); + } + return ids; +} + +describe("dashboard tile destinations", () => { + it("reads a non-trivial tile set from the registry and the render code", () => { + expect(TILE_CAPABLE_WIDGET_IDS.length).toBeGreaterThanOrEqual(18); + expect(renderedTileIds().length).toBeGreaterThanOrEqual(19); + }); + + it("knows every tile the dashboard renders, and renders every known tile", () => { + const rendered = renderedTileIds(); + const widgets = new Set(); + for (const id of rendered) { + const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id; + const widget = tileWidgetId(sample); + expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull(); + widgets.add(widget as string); + } + expect([...widgets].sort()).toEqual([...TILE_CAPABLE_WIDGET_IDS].sort()); + }); + + it("routes the dashboard's tile links through this map and no other", () => { + const source = readFileSync( + path.join(ROOT, "src/app/page-client.tsx"), + "utf8", + ); + expect(source).toMatch(/dashboardTileHref\(entry\.id,/); + expect(source).not.toMatch(/"\/insights\/[a-z-]+"/); + }); + + it("declares the series of every tile", () => { + expect(Object.keys(TILE_TYPES).sort()).toEqual( + [...TILE_CAPABLE_WIDGET_IDS].sort(), + ); + }); + + it.each([...TILE_CAPABLE_WIDGET_IDS])( + "%s opens the page of the metric it shows", + (widget) => { + const destination = TILE_DESTINATION[widget]; + const pages = new Set( + TILE_TYPES[widget] + .map((type) => subPageSlugForType(type)) + .filter((slug): slug is NonNullable => slug != null), + ); + if (pages.size > 0) { + expect( + destination, + `${widget} shows ${TILE_TYPES[widget].join(", ")}, which has a page, but links nowhere`, + ).not.toBeNull(); + expect(pages.has(destination!)).toBe(true); + return; + } + const nonMeasurement = NON_MEASUREMENT_DESTINATION[widget]; + if (nonMeasurement) { + expect(destination).toBe(nonMeasurement.slug); + expect(nonMeasurement.reason.length).toBeGreaterThan(10); + return; + } + expect(destination).toBeNull(); + expect( + NO_DESTINATION[widget], + `${widget} has no destination and no written reason`, + ).toBeTruthy(); + }, + ); + + it("allowlists only tiles that really lead nowhere", () => { + for (const widget of Object.keys(NO_DESTINATION) as TileCapableWidgetId[]) { + expect(TILE_DESTINATION[widget]).toBeNull(); + } + }); + + it("links only to sub-pages that exist as routes", () => { + const slugs = Object.values(TILE_DESTINATION).filter( + (slug): slug is NonNullable => slug !== null, + ); + expect(slugs.length).toBeGreaterThanOrEqual(18); + for (const slug of slugs) { + expect( + existsSync(path.join(ROOT, "src/app/insights", slug, "page.tsx")), + `/insights/${slug} has no page`, + ).toBe(true); + } + }); + + it("never links a tile to a page owned by a different module", () => { + for (const widget of TILE_CAPABLE_WIDGET_IDS) { + const slug = TILE_DESTINATION[widget]; + if (slug === null) continue; + const pageOwner = surfaceModule(`insights-page:${slug}`); + if (pageOwner === undefined) continue; + expect( + surfaceModule(`widget:${widget}`), + `${widget} links to /insights/${slug}, owned by ${pageOwner}`, + ).toBe(pageOwner); + } + }); + + it("resolves the rendered tile ids to their hrefs", () => { + expect(dashboardTileHref("bodyFat", undefined)).toBe("/insights/body-fat"); + expect(dashboardTileHref("bp-sys", undefined)).toBe( + "/insights/blood-pressure", + ); + expect(dashboardTileHref("bp-dia", null)).toBe("/insights/blood-pressure"); + expect(dashboardTileHref("glucose-FASTING", {})).toBe( + "/insights/blood-glucose", + ); + expect(dashboardTileHref("not-a-tile", undefined)).toBeNull(); + }); + + it("drops the link when the destination's module is switched off", () => { + expect(dashboardTileHref("mood", { mood: false })).toBeNull(); + expect(dashboardTileHref("glucose-FASTING", { glucose: false })).toBeNull(); + expect(dashboardTileHref("waterIntake", { nutrients: false })).toBeNull(); + expect(dashboardTileHref("bodyFat", { mood: false })).toBe( + "/insights/body-fat", + ); + }); +}); diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index 80577cffa..d219ffa9a 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -11,7 +11,10 @@ import { GLUCOSE_CONTEXT_BUCKETS, type GlucoseContextBucket, } from "@/lib/glucose"; -import type { DashboardLayout } from "@/lib/dashboard-layout"; +import type { + DashboardLayout, + DashboardWidgetId, +} from "@/lib/dashboard-layout"; /** * v1.7.0 — first-paint gate for the dashboard tile strip. @@ -49,7 +52,7 @@ export function resolveDashboardFirstPaintGate(input: { * path at all — counting any of them over-reserved the loading * silhouette and made the strip reshuffle when the data landed. */ -const TILE_CAPABLE_WIDGET_IDS = new Set([ +export const TILE_CAPABLE_WIDGET_IDS = [ "weight", "bp", "pulse", @@ -72,7 +75,13 @@ const TILE_CAPABLE_WIDGET_IDS = new Set([ // v1.29 — fluid intake strip tile: paints one strip card and self-gates // on having a NUTRIENT_WATER sample. "waterIntake", -]); +] as const satisfies readonly DashboardWidgetId[]; + +export type TileCapableWidgetId = (typeof TILE_CAPABLE_WIDGET_IDS)[number]; + +const TILE_CAPABLE_WIDGET_ID_SET: ReadonlySet = new Set( + TILE_CAPABLE_WIDGET_IDS, +); /** * v1.16.8 — silhouette count for the tile-strip skeleton: one card per @@ -85,7 +94,7 @@ const TILE_CAPABLE_WIDGET_IDS = new Set([ export function resolveConfiguredTileCount(layout: DashboardLayout): number { let count = 0; for (const widget of layout.widgets) { - if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!TILE_CAPABLE_WIDGET_ID_SET.has(widget.id)) continue; if (!(widget.tileVisible ?? widget.visible)) continue; count += widget.id === "bp" ? 2 : 1; } diff --git a/src/components/dashboard/tile-destinations.ts b/src/components/dashboard/tile-destinations.ts new file mode 100644 index 000000000..b4602929d --- /dev/null +++ b/src/components/dashboard/tile-destinations.ts @@ -0,0 +1,81 @@ +/** + * Where each dashboard strip tile leads. + * + * A tile shows one metric, and every metric with an Insights sub-page should + * open it on click. The map is keyed by the tile-capable widget id and typed + * against the sub-page slug union, so a destination that does not exist is a + * compile error and a tile added to `TILE_CAPABLE_WIDGET_IDS` without a + * decision here is one too. `null` means the tile deliberately leads nowhere; + * the guard in `__tests__/tile-destinations.test.ts` demands a written reason + * for every such entry and fails when a tile's metric has a sub-page the tile + * does not open. It exists because the body fat tile was the one tile that + * led nowhere, documented as having "no dedicated sub-page" while every + * body-composition sibling had one. + * + * A link never points at a page the account cannot see: the destination + * passes the same `insights-page:` surface gate the tab strip uses. + */ +import { + INSIGHTS_OVERVIEW_PATH, + type SubPageSlug, +} from "@/lib/insights/sub-page-metric"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; + +import { + TILE_CAPABLE_WIDGET_IDS, + type TileCapableWidgetId, +} from "./dashboard-gates"; + +export const TILE_DESTINATION: Readonly< + Record +> = { + weight: "weight", + bp: "blood-pressure", + pulse: "pulse", + bodyFat: "body-fat", + mood: "mood", + sleep: "sleep", + steps: "steps", + glucose: "blood-glucose", + bpInTarget: "blood-pressure", + vo2Max: "cardio-fitness", + hrv: "hrv", + oxygenSaturation: "oxygen", + respiratoryRate: "respiratory-rate", + wristTemperature: "wrist-temperature", + muscleMass: "muscle-mass", + totalBodyWater: "body-water", + boneMass: "bone-mass", + // Fluid intake lives on the nutrients page (hydration hero + quick-add), + // not on a Measurement-backed detail page. + waterIntake: "nutrients", +}; + +const TILE_WIDGET_IDS: ReadonlySet = new Set(TILE_CAPABLE_WIDGET_IDS); + +/** + * The widget a rendered tile belongs to. Blood pressure paints two tiles + * (`bp-sys`, `bp-dia`) and glucose one per logged context (`glucose-`); + * every other tile carries its widget id verbatim. + */ +export function tileWidgetId(tileId: string): TileCapableWidgetId | null { + if (tileId === "bp-sys" || tileId === "bp-dia") return "bp"; + if (tileId.startsWith("glucose-")) return "glucose"; + return TILE_WIDGET_IDS.has(tileId) ? (tileId as TileCapableWidgetId) : null; +} + +/** + * The href a rendered tile links to, or null when it has no destination or + * the destination page is hidden by a switched-off module. + */ +export function dashboardTileHref( + tileId: string, + modules: SurfaceModuleMap | null | undefined, +): string | null { + const widget = tileWidgetId(tileId); + if (widget === null) return null; + const slug = TILE_DESTINATION[widget]; + if (slug === null) return null; + if (!isSurfaceVisible(`insights-page:${slug}`, modules)) return null; + return `${INSIGHTS_OVERVIEW_PATH}/${slug}`; +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 6017824e3..9bbcf005c 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -233,6 +233,7 @@ export const SUB_PAGE_TABS: Record< // ── body composition ── weight: { labelKey: "insights.navWeight", metric: "WEIGHT" }, bmi: { labelKey: "insights.navBmi", metric: "BMI" }, + "body-fat": { labelKey: "insights.navBodyFat", metric: "BODY_FAT" }, "body-water": { labelKey: "insights.navTotalBodyWater", metric: "TOTAL_BODY_WATER", diff --git a/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts index ffaa085f0..5c1d34868 100644 --- a/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts +++ b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts @@ -34,10 +34,12 @@ describe("type → insights sub-page slug", () => { expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps"); expect(subPageSlugForType("MOOD")).toBe("mood"); expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose"); + // Body fat gained its own page so the dashboard tile has somewhere to go; + // it used to fall back to the filtered measurements list. + expect(subPageSlugForType("BODY_FAT")).toBe("body-fat"); }); it("returns undefined for a type with no sub-page", () => { - expect(subPageSlugForType("BODY_FAT")).toBeUndefined(); expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined(); expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined(); }); diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 74e790e44..32e5f79e1 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -82,6 +82,9 @@ export type InsightMetric = | "BLOOD_GLUCOSE" | "TOTAL_BODY_WATER" | "BONE_MASS" + // Body fat percentage, keyed on its MeasurementType like the rest of the + // body-composition pages. + | "BODY_FAT" | "FLIGHTS_CLIMBED" | "WALKING_RUNNING_DISTANCE" | "FAT_FREE_MASS" diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 7b2cb26ad..3884c1a5c 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -66,6 +66,10 @@ const SUB_PAGE_METRIC = { weight: ["WEIGHT"], // BMI is derived from WEIGHT + profile height (no separate series). bmi: ["WEIGHT"], + // Body fat percentage. The dashboard has tiled and charted it since the + // first release, but it never had a page of its own, so its tile led + // nowhere while every sibling below opened one. + "body-fat": ["BODY_FAT"], // v1.7.0 — Withings / Apple body-composition tail. "body-water": ["TOTAL_BODY_WATER"], "bone-mass": ["BONE_MASS"], @@ -199,6 +203,7 @@ export const SUB_PAGE_GROUP: Partial> = { "respiratory-rate": "vitals", // body composition (v1.7.0) bmi: "body", + "body-fat": "body", "body-water": "body", "bone-mass": "body", "fat-free-mass": "body", @@ -387,7 +392,7 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< * `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that * only ever appears on a multi-metric page (the two blood-pressure * components, the two HRV flavours) falls back to that page. Types with no - * sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most + * sub-page at all (RECOVERY_SCORE, the `*_EVENT` series, most * device scores) are absent — the caller falls back to the filtered * measurements list. */