diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts new file mode 100644 index 000000000..2f8f3a24a --- /dev/null +++ b/e2e/settings-score.spec.ts @@ -0,0 +1,293 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { settleBeforeMeasure } from "./utils/settle"; + +/** + * Settings → Health Score: the surface that decides which pillars count. + * + * Everything asserted here goes through stable `data-slot` / `data-*` + * attributes rather than viewport-dependent text, so the spec keeps working + * at any width and in any locale (only the copy assertions read English, and + * the suite pins the English locale through the storage state). + * + * The refusal case is driven through the REAL write. Deselecting down to + * activity and wellbeing genuinely fails the server's breadth rule, and a + * refused write persists nothing, so the case leaves the shared account + * exactly as it found it. Mocking the 422 would have proved that the page + * can render a message, not that the rule is enforced or that the page reads + * the reason the server actually sends. + * + * The analytics read IS mocked, because it is the page's secondary read and + * the states it drives — waiting for data, safety signposting — depend on + * what the seeded account happens to hold. Fixing it makes those two rows + * deterministic without touching the write path under test. + */ + +/** A slim Health Score report: only the fields the settings page reads. */ +function scoreReport() { + const coverage = { + requiredInputs: 3, + presentInputs: 2, + historyDays: 14, + missing: [], + }; + const provenance = { + inputs: [], + source: "live", + windowDays: 90, + computedAt: new Date().toISOString(), + }; + const insufficient = (reason: string) => ({ + status: "insufficient", + reason, + coverage, + provenance, + }); + return { + composite: insufficient("three_domains_required"), + pillars: [ + { + id: "LIPIDS", + domain: "cardiometabolic", + result: insufficient("not_tracked"), + }, + { + id: "WELLBEING", + domain: "wellbeing", + result: insufficient("crisis_signposting"), + }, + { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: insufficient("read_failed"), + }, + ], + delta: null, + deltaReason: "no_current_score", + scoreVersion: 2, + weightGoal: insufficient("no_goal"), + // Raised and never dismissed, which is the state every account is in: + // nothing has ever rendered this notice. + algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false }, + }; +} + +async function mockAnalytics(page: import("@playwright/test").Page) { + await page.route(/\/api\/analytics(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, healthScore: scoreReport() }, + error: null, + }), + }); + }); +} + +test.describe("Settings → Health Score", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await mockAnalytics(page); + }); + + test("groups the pillars by area and makes the three axes legible", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + // The heading + subheading pair the settings IA requires, owned by the + // shell and not re-declared inside the card. + await expect(page.locator("#settings-section-score-title")).toBeVisible(); + await expect( + page.locator("#settings-section-score-title + p"), + ).toBeVisible(); + + // One concept, one card. A second card beside it would be the + // top/bottom split of one decision the rules forbid; the notice is a + // different concept and is allowed its own. + await expect(page.locator('[data-slot="score-config-card"]')).toHaveCount( + 1, + ); + + // The cardiometabolic triple, visibly under one heading. + const cardio = page.locator( + '[data-slot="score-config-group"][data-domain="cardiometabolic"]', + ); + await expect(cardio).toHaveCount(1); + await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount( + 3, + ); + await expect( + cardio.locator('[data-slot="score-config-group-note"]'), + ).toBeVisible(); + + // Every visible row names recording and showing beside its switch, and + // the card says in words that this page leaves those two alone. + const rows = page.locator('[data-slot="score-pillar-row"]'); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + await expect( + page.locator('[data-slot="score-pillar-axis-recorded"]'), + ).toHaveCount(rowCount); + await expect( + page.locator('[data-slot="score-pillar-axis-shown"]'), + ).toHaveCount(rowCount); + await expect( + page.locator('[data-slot="score-config-three-axes"]'), + ).toContainText("keeps being recorded"); + }); + + test("shows the pillar it cannot score as present, disabled and honest", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const fitness = page.locator( + '[data-slot="score-pillar-row"][data-pillar="FITNESS"]', + ); + await settleBeforeMeasure(page, fitness); + + await expect(fitness).toHaveAttribute("data-selectable", "false"); + await expect(fitness).toHaveAttribute("data-eligibility", "unavailable"); + // No switch at all, rather than one that no-ops behind `disabled`. + await expect( + fitness.locator('[data-slot="score-pillar-switch"]'), + ).toHaveCount(0); + await expect( + fitness.locator('[data-slot="score-pillar-unavailable"]'), + ).toHaveAttribute("aria-disabled", "true"); + }); + + test("tells waiting for data apart from safety guidance and a failed read", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + const state = (pillar: string) => + page.locator( + `[data-slot="score-pillar-row"][data-pillar="${pillar}"] [data-slot="score-pillar-state"]`, + ); + + await expect(state("LIPIDS")).toHaveAttribute("data-state", "waiting"); + // Safety signposting is its own state and never a configuration error. + await expect(state("WELLBEING")).toHaveAttribute("data-state", "crisis"); + // A failed read is a failure, not absence. + await expect(state("BLOOD_PRESSURE")).toHaveAttribute( + "data-state", + "read_failed", + ); + }); + + test("renders the score notice and lets it be dismissed", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const notice = page.locator('[data-slot="score-change-notice"]'); + await settleBeforeMeasure(page, notice); + + await expect(notice).toBeVisible(); + + let dismissed: string | null = null; + await page.route(/\/api\/daily\/digest\/dismiss$/, async (route) => { + dismissed = String(route.request().postDataJSON()?.itemKey ?? ""); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { dismissed: true }, error: null }), + }); + }); + // The refetch after the dismissal has to come back already dismissed, + // or the notice would simply be raised again and the test would be + // asserting a race rather than the behaviour. + await page.route(/\/api\/analytics(\?|$)/, async (route) => { + const report = scoreReport(); + report.algorithmNotice.dismissed = true; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, healthScore: report }, + error: null, + }), + }); + }); + + await notice.locator('[data-slot="score-change-notice-dismiss"]').click(); + + await expect(notice).toHaveCount(0); + expect(dismissed).toBe("health_score_algorithm:2"); + }); + + test("refuses a selection too narrow to produce a score, in plain language", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + // Start from everything, then take out every pillar except activity and + // wellbeing: two areas of health and no physical measurement, which the + // server refuses. + await page.locator('[data-slot="score-config-preset-all"]').click(); + const keep = new Set(["ACTIVITY", "WELLBEING"]); + const rows = page.locator( + '[data-slot="score-pillar-row"][data-selectable="true"]', + ); + for (let index = 0; index < (await rows.count()); index += 1) { + const row = rows.nth(index); + const pillar = await row.getAttribute("data-pillar"); + if (!pillar || keep.has(pillar)) continue; + if ((await row.getAttribute("data-counts")) === "false") continue; + await row.locator('[data-slot="score-pillar-switch"]').click(); + } + + await page.locator('[data-slot="score-config-save"]').click(); + + const refusal = page.locator('[data-slot="score-config-refusal"]'); + await expect(refusal).toBeVisible(); + await expect(refusal).toHaveAttribute("role", "alert"); + // The server's machine reason, rendered as a sentence a person can act + // on, not as an error code and not as the server's English fallback. + await expect(refusal).toContainText("physical measurement"); + await expect(refusal).not.toContainText("_required"); + }); + + test("keeps the content column the same width as its neighbours", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven assertion; desktop project only", + ); + await page.setViewportSize({ width: 1440, height: 900 }); + + async function columnWidth(route: string, sentinel: string) { + await page.goto(route, { waitUntil: "domcontentloaded" }); + await settleBeforeMeasure(page, page.locator(sentinel)); + return page.evaluate(() => { + const column = document.querySelector("section[aria-labelledby]"); + const grid = column?.closest("div.grid") ?? null; + const cell = grid?.lastElementChild ?? null; + return cell ? Math.round(cell.getBoundingClientRect().width) : -1; + }); + } + + const modules = await columnWidth( + "/settings/modules", + '[data-slot="card"]', + ); + const score = await columnWidth( + "/settings/score", + '[data-slot="score-config-card"]', + ); + + expect(modules).toBeGreaterThan(0); + expect(Math.abs(score - modules)).toBeLessThanOrEqual(1); + }); +}); diff --git a/messages/de.json b/messages/de.json index d70772cab..7a6964ab4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,7 +2268,7 @@ "yellow": "Einige Bereiche brauchen Aufmerksamkeit", "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, - "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", + "insufficient": "{count} von {required} Feldern der Gesundheit haben genug aktuelle Daten. Der Score braucht {required}, mindestens eines davon körperlich gemessen.", "anatomyToggle": "Wie dieser Score zustande kommt", "notScored": "Noch nicht bewertet ({count})", "method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Erfasse dein Gewicht, um den Fortschritt zum Ziel zu sehen.", "read_failed": "Gewichtsdaten konnten nicht geladen werden. Versuche es erneut.", "notScored": "Der Fortschritt zum Gewichtsziel ist persönlicher Kontext und beeinflusst den Gesundheitsscore nicht." + }, + "configureLink": "Festlegen, was in deinen Score einfließt", + "coverage": { + "minimumMet": "Deckt die drei Felder ab, die der Score braucht", + "areas": "{present} von {required} Feldern der Gesundheit" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} von {required} Eingaben · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basiert auf {count} Tagen Verlauf", - "missing": "Erfasse diese, um den Wert zu schärfen: {list}" + "missing": "Erfasse diese, um den Wert zu schärfen: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Wert {score} von 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).", "fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.", "fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}." + }, + "score": { + "title": "Gesundheitsscore", + "subtitle": "Lege fest, welche Bereiche in deinen Score einfließen.", + "card": { + "title": "Was in deinen Score einfließt", + "description": "Jeder Bereich, den du aufzeichnest, zählt mit, solange du hier nichts anderes sagst." + }, + "threeAxes": "Aufzeichnen, Anzeigen und Mitzählen sind drei verschiedene Dinge. Diese Seite ändert nur das Letzte. Ein Bereich, den du hier abschaltest, wird weiter aufgezeichnet und weiter überall in HealthLog angezeigt, er bewegt nur die Zahl nicht mehr.", + "axis": { + "recorded": "Wird aufgezeichnet", + "shown": "Wird in der App angezeigt", + "countsFor": "{pillar} in den Score einrechnen" + }, + "domain": { + "cardiometabolic": "Herz und Stoffwechsel", + "activity": "Aktivität", + "sleep": "Schlaf", + "adiposity": "Körperform", + "wellbeing": "Wohlbefinden", + "fitness": "Gemessene Fitness" + }, + "domainNote": { + "cardiometabolic": "Drei Bereiche, ein Feld der Gesundheit. Jeder Bereich zählt im Score gleich viel, dieses Feld wiegt also dreimal so schwer wie eines mit nur einem Bereich." + }, + "state": { + "counting": "Zählt in deinen Score", + "waiting": "Ausgewählt, wartet auf Daten", + "crisis": "Statt eines Werts wird ein Sicherheitshinweis gezeigt. An deiner Auswahl liegt es nicht.", + "readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden.", + "unavailable": "Noch nicht verfügbar", + "unavailableDetail": "HealthLog kann einen gemessenen Fitnesstest bisher nicht von einer Geräteschätzung unterscheiden, deshalb hat dieser Bereich noch nie einen Wert ergeben. Er bleibt in der Liste, damit du siehst, dass er nicht mitzählt." + }, + "presets": { + "label": "Ausgangspunkt", + "all": "Alle Bereiche", + "current": "Deine gespeicherte Auswahl" + }, + "save": "Speichern", + "saved": "Gespeichert.", + "error": "Konnte nicht gespeichert werden. Bitte versuche es erneut.", + "loadFailed": "Deine Auswahl konnte nicht geladen werden.", + "eligibilityFailed": "Es ließ sich nicht prüfen, für welche Bereiche gerade Daten vorliegen. Die Schalter unten funktionieren trotzdem.", + "omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.", + "omittedLink": "Module öffnen", + "refusal": { + "physiological": "Ein Health Score braucht mindestens einen körperlich gemessenen Wert. Behalte Blutdruck, Glukose, Schlaf, Körperform oder Cholesterin in deiner Auswahl, zusätzlich zu allem anderen, was du wählst.", + "domains": "Ein Health Score braucht mindestens drei verschiedene Felder der Gesundheit. Blutdruck, Glukose und Cholesterin beschreiben dasselbe Feld, eine Auswahl nur daraus zählt also als eines." + }, + "notice": { + "upgrade": { + "title": "Was zählt, entscheidest jetzt du", + "body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." + }, + "changed": { + "title": "Du hast geändert, was zählt", + "body": "Seit {date} wird dein Score aus einer anderen Zusammenstellung berechnet. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch.", + "bodyUndated": "Dein Score wird aus einer anderen Zusammenstellung berechnet als früher. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch." + }, + "dismiss": "Verstanden", + "dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut." + } } }, "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index 663caae50..85efbc250 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,7 +2268,7 @@ "yellow": "Some pillars need attention", "red": "One or more pillars are outside the reference range" }, - "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", + "insufficient": "{count} of {required} areas of health have enough recent data. The score needs {required}, at least one of them a physical measurement.", "anatomyToggle": "How this score comes about", "notScored": "Not scored yet ({count})", "method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Log a weight reading to see progress toward your goal.", "read_failed": "Weight data could not be loaded. Try again.", "notScored": "Weight-goal progress is personal context and does not affect the Health Score." + }, + "configureLink": "Choose what counts toward your score", + "coverage": { + "minimumMet": "Covers the three areas the score needs", + "areas": "{present} of {required} areas of health" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} of {required} inputs · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Based on {count} days of history", - "missing": "Track these to sharpen this: {list}" + "missing": "Track these to sharpen this: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Score {score} out of 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "The last background weather update failed ({when}).", "fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.", "fetchFailedOther": "The last {count} background weather updates failed, most recently {when}." + }, + "score": { + "title": "Health Score", + "subtitle": "Choose which pillars count toward your score.", + "card": { + "title": "What counts toward your score", + "description": "Every pillar you record counts unless you say otherwise here." + }, + "threeAxes": "Recording, showing and counting are three separate things. This page changes only the last one. A pillar you switch off here keeps being recorded and keeps appearing everywhere else in HealthLog, it just stops moving the number.", + "axis": { + "recorded": "Being recorded", + "shown": "Shown in the app", + "countsFor": "Count {pillar} toward the score" + }, + "domain": { + "cardiometabolic": "Heart and metabolism", + "activity": "Activity", + "sleep": "Sleep", + "adiposity": "Body shape", + "wellbeing": "Wellbeing", + "fitness": "Measured fitness" + }, + "domainNote": { + "cardiometabolic": "Three pillars, one area of health. Each pillar carries the same weight in the score, so this area weighs three times as much as an area with a single pillar." + }, + "state": { + "counting": "Counting toward your score", + "waiting": "Selected, waiting for data", + "crisis": "Safety guidance is shown instead of a score. Your selection is fine.", + "readFailed": "This pillar could not be read on the last run.", + "unavailable": "Not available yet", + "unavailableDetail": "HealthLog cannot yet tell a measured fitness test apart from a device estimate, so this pillar has never produced a score. It stays listed so you can see it is not counted." + }, + "presets": { + "label": "Start from", + "all": "All pillars", + "current": "Your saved selection" + }, + "save": "Save", + "saved": "Saved.", + "error": "Couldn't save. Please try again.", + "loadFailed": "Couldn't load your selection.", + "eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.", + "omitted": "{count} pillars are not listed because their tracking is switched off. Turn a module back on and its pillar comes back.", + "omittedLink": "Open modules", + "refusal": { + "physiological": "A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.", + "domains": "A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one." + }, + "notice": { + "upgrade": { + "title": "What counts is now yours to choose", + "body": "Which pillars counted toward your Health Score used to follow the modules you had switched on. It is a separate choice now, and this page is where you make it. Your number did not move when this arrived: it still counts exactly what it counted before." + }, + "changed": { + "title": "You changed what counts", + "body": "Since {date} your score is worked out from a different set of pillars. Days before that were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it.", + "bodyUndated": "Your score is worked out from a different set of pillars than it used to be. Days before the change were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it." + }, + "dismiss": "Got it", + "dismissError": "Couldn't save that. Please try again." + } } }, "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index 6c74b5443..b008bd088 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,7 +2268,7 @@ "yellow": "Algunos pilares requieren atención", "red": "Uno o más pilares están fuera del intervalo de referencia" }, - "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", + "insufficient": "{count} de {required} ámbitos de salud tienen suficientes datos recientes. La puntuación necesita {required}, y al menos uno debe ser una medición física.", "anatomyToggle": "Cómo se forma esta puntuación", "notScored": "Aún sin puntuar ({count})", "method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Registra una lectura de peso para ver el progreso hacia tu objetivo.", "read_failed": "No se pudieron cargar los datos de peso. Inténtalo de nuevo.", "notScored": "El progreso del objetivo de peso es contexto personal y no afecta a la puntuación de salud." + }, + "configureLink": "Elegir qué cuenta para tu puntuación", + "coverage": { + "minimumMet": "Cubre los tres ámbitos que la puntuación necesita", + "areas": "{present} de {required} ámbitos de salud" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} de {required} entradas · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basado en {count} días de historial", - "missing": "Registra estos para afinar el valor: {list}" + "missing": "Registra estos para afinar el valor: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Puntuación {score} de 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).", "fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.", "fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}." + }, + "score": { + "title": "Puntuación de salud", + "subtitle": "Elige qué pilares cuentan para tu puntuación.", + "card": { + "title": "Qué cuenta para tu puntuación", + "description": "Cada pilar que registras cuenta, salvo que digas lo contrario aquí." + }, + "threeAxes": "Registrar, mostrar y contar son tres cosas distintas. Esta página cambia solo la última. Un pilar que desactives aquí se sigue registrando y se sigue viendo en el resto de HealthLog, simplemente deja de mover el número.", + "axis": { + "recorded": "Se registra", + "shown": "Se muestra en la app", + "countsFor": "Contar {pillar} para la puntuación" + }, + "domain": { + "cardiometabolic": "Corazón y metabolismo", + "activity": "Actividad", + "sleep": "Sueño", + "adiposity": "Forma corporal", + "wellbeing": "Bienestar", + "fitness": "Condición física medida" + }, + "domainNote": { + "cardiometabolic": "Tres pilares, un mismo ámbito de salud. Cada pilar pesa lo mismo en la puntuación, así que este ámbito pesa el triple que uno con un solo pilar." + }, + "state": { + "counting": "Cuenta para tu puntuación", + "waiting": "Seleccionado, esperando datos", + "crisis": "En lugar de una puntuación se muestra orientación de seguridad. Tu selección está bien.", + "readFailed": "Este pilar no se pudo leer en el último cálculo.", + "unavailable": "Todavía no disponible", + "unavailableDetail": "HealthLog aún no distingue una prueba de condición física medida de una estimación del dispositivo, así que este pilar nunca ha dado una puntuación. Sigue en la lista para que veas que no cuenta." + }, + "presets": { + "label": "Punto de partida", + "all": "Todos los pilares", + "current": "Tu selección guardada" + }, + "save": "Guardar", + "saved": "Guardado.", + "error": "No se pudo guardar. Inténtalo de nuevo.", + "loadFailed": "No se pudo cargar tu selección.", + "eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.", + "omitted": "{count} pilares no aparecen porque su seguimiento está desactivado. Vuelve a activar un módulo y su pilar regresa.", + "omittedLink": "Abrir módulos", + "refusal": { + "physiological": "Una puntuación de salud necesita al menos una medición física. Mantén en tu selección la presión arterial, la glucosa, el sueño, la forma corporal o el colesterol, además de lo demás que elijas.", + "domains": "Una puntuación de salud necesita al menos tres ámbitos de salud distintos. La presión arterial, la glucosa y el colesterol describen el mismo ámbito, así que una selección hecha solo con ellos cuenta como uno." + }, + "notice": { + "upgrade": { + "title": "Ahora decides tú qué cuenta", + "body": "Qué pilares contaban para tu Health Score dependía antes de los módulos que tuvieras activados. Ahora es una decisión aparte, y aquí es donde la tomas. Tu número no se movió al llegar este cambio: sigue contando exactamente lo que contaba antes." + }, + "changed": { + "title": "Has cambiado qué cuenta", + "body": "Desde el {date} tu puntuación se calcula con otro conjunto de pilares. Los días anteriores salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción.", + "bodyUndated": "Tu puntuación se calcula con un conjunto de pilares distinto al anterior. Los días previos salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción." + }, + "dismiss": "Entendido", + "dismissError": "No se pudo guardar. Inténtalo de nuevo." + } } }, "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index 61e459126..8872899d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,7 +2268,7 @@ "yellow": "Certains piliers demandent une attention", "red": "Un ou plusieurs piliers sont hors de la plage de référence" }, - "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", + "insufficient": "{count} domaines de santé sur {required} disposent d'assez de données récentes. Le score en demande {required}, dont au moins un issu d'une mesure physique.", "anatomyToggle": "Comment ce score est établi", "notScored": "Pas encore évalué ({count})", "method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Enregistre ton poids pour suivre ta progression vers ton objectif.", "read_failed": "Les données de poids n'ont pas pu être chargées. Réessaie.", "notScored": "La progression vers l'objectif de poids est un contexte personnel et ne modifie pas le score de santé." + }, + "configureLink": "Choisir ce qui compte dans votre score", + "coverage": { + "minimumMet": "Couvre les trois domaines exigés par le score", + "areas": "{present} domaines de santé sur {required}" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} entrées sur {required} · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basé sur {count} jours d'historique", - "missing": "Enregistre ceci pour affiner la valeur : {list}" + "missing": "Enregistre ceci pour affiner la valeur : {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Score {score} sur 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).", "fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.", "fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}." + }, + "score": { + "title": "Score de santé", + "subtitle": "Choisissez les piliers qui comptent dans votre score.", + "card": { + "title": "Ce qui compte dans votre score", + "description": "Chaque pilier que vous enregistrez compte, sauf indication contraire ici." + }, + "threeAxes": "Enregistrer, afficher et compter sont trois choses distinctes. Cette page ne change que la dernière. Un pilier désactivé ici continue d'être enregistré et reste visible partout ailleurs dans HealthLog, il cesse simplement de faire bouger le chiffre.", + "axis": { + "recorded": "Enregistré", + "shown": "Affiché dans l'application", + "countsFor": "Compter {pillar} dans le score" + }, + "domain": { + "cardiometabolic": "Cœur et métabolisme", + "activity": "Activité", + "sleep": "Sommeil", + "adiposity": "Silhouette", + "wellbeing": "Bien-être", + "fitness": "Condition physique mesurée" + }, + "domainNote": { + "cardiometabolic": "Trois piliers, un seul domaine de santé. Chaque pilier pèse autant dans le score, ce domaine pèse donc trois fois plus qu'un domaine composé d'un seul pilier." + }, + "state": { + "counting": "Compte dans votre score", + "waiting": "Sélectionné, en attente de données", + "crisis": "Un message de sécurité s'affiche à la place d'un score. Votre sélection n'est pas en cause.", + "readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul.", + "unavailable": "Pas encore disponible", + "unavailableDetail": "HealthLog ne sait pas encore distinguer un test de condition physique mesuré d'une estimation d'appareil, ce pilier n'a donc jamais produit de score. Il reste listé pour que vous voyiez qu'il ne compte pas." + }, + "presets": { + "label": "Point de départ", + "all": "Tous les piliers", + "current": "Votre sélection enregistrée" + }, + "save": "Enregistrer", + "saved": "Enregistré.", + "error": "Enregistrement impossible. Veuillez réessayer.", + "loadFailed": "Impossible de charger votre sélection.", + "eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.", + "omitted": "{count} piliers ne sont pas listés parce que leur suivi est désactivé. Réactivez un module et son pilier revient.", + "omittedLink": "Ouvrir les modules", + "refusal": { + "physiological": "Un score de santé a besoin d'au moins une mesure physique. Gardez la tension, la glycémie, le sommeil, la silhouette ou le cholestérol dans votre sélection, en plus de ce que vous choisissez par ailleurs.", + "domains": "Un score de santé a besoin d'au moins trois domaines de santé différents. La tension, la glycémie et le cholestérol décrivent le même domaine, une sélection composée uniquement de ceux-là compte donc pour un." + }, + "notice": { + "upgrade": { + "title": "Ce qui compte, c'est vous qui le décidez", + "body": "Les piliers qui comptaient dans votre Health Score suivaient auparavant les modules que vous aviez activés. C'est désormais un choix à part, et c'est ici que vous le faites. Votre chiffre n'a pas bougé pour autant : il compte exactement ce qu'il comptait avant." + }, + "changed": { + "title": "Vous avez changé ce qui compte", + "body": "Depuis le {date}, votre score est calculé à partir d'un autre ensemble de piliers. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture.", + "bodyUndated": "Votre score est calculé à partir d'un autre ensemble de piliers qu'auparavant. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture." + }, + "dismiss": "Compris", + "dismissError": "Enregistrement impossible. Veuillez réessayer." + } } }, "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index ba528d8ca..2d3abdcf0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,7 +2268,7 @@ "yellow": "Alcuni pilastri richiedono attenzione", "red": "Uno o più pilastri sono fuori dall'intervallo di riferimento" }, - "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", + "insufficient": "{count} ambiti di salute su {required} hanno abbastanza dati recenti. Il punteggio ne richiede {required}, di cui almeno uno misurato fisicamente.", "anatomyToggle": "Come nasce questo punteggio", "notScored": "Non ancora valutato ({count})", "method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Registra il peso per vedere i progressi verso il tuo obiettivo.", "read_failed": "Non è stato possibile caricare i dati del peso. Riprova.", "notScored": "I progressi verso l'obiettivo di peso sono un contesto personale e non influenzano il punteggio di salute." + }, + "configureLink": "Scegli cosa conta nel tuo punteggio", + "coverage": { + "minimumMet": "Copre i tre ambiti richiesti dal punteggio", + "areas": "{present} ambiti di salute su {required}" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} di {required} input · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basato su {count} giorni di cronologia", - "missing": "Registra questi per affinare il valore: {list}" + "missing": "Registra questi per affinare il valore: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Punteggio {score} su 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).", "fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.", "fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}." + }, + "score": { + "title": "Punteggio di salute", + "subtitle": "Scegli quali pilastri contano per il tuo punteggio.", + "card": { + "title": "Cosa conta per il tuo punteggio", + "description": "Ogni pilastro che registri conta, a meno che tu non dica diversamente qui." + }, + "threeAxes": "Registrare, mostrare e contare sono tre cose diverse. Questa pagina cambia solo l'ultima. Un pilastro che disattivi qui continua a essere registrato e resta visibile ovunque in HealthLog, smette solo di muovere il numero.", + "axis": { + "recorded": "Viene registrato", + "shown": "Mostrato nell'app", + "countsFor": "Conta {pillar} nel punteggio" + }, + "domain": { + "cardiometabolic": "Cuore e metabolismo", + "activity": "Attività", + "sleep": "Sonno", + "adiposity": "Forma corporea", + "wellbeing": "Benessere", + "fitness": "Forma fisica misurata" + }, + "domainNote": { + "cardiometabolic": "Tre pilastri, un solo ambito di salute. Ogni pilastro pesa allo stesso modo nel punteggio, quindi questo ambito pesa il triplo di uno con un solo pilastro." + }, + "state": { + "counting": "Conta nel tuo punteggio", + "waiting": "Selezionato, in attesa di dati", + "crisis": "Al posto di un punteggio viene mostrata un'indicazione di sicurezza. La tua selezione va bene.", + "readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo.", + "unavailable": "Non ancora disponibile", + "unavailableDetail": "HealthLog non sa ancora distinguere un test di forma fisica misurato da una stima del dispositivo, perciò questo pilastro non ha mai prodotto un punteggio. Resta in elenco perché tu veda che non conta." + }, + "presets": { + "label": "Punto di partenza", + "all": "Tutti i pilastri", + "current": "La tua selezione salvata" + }, + "save": "Salva", + "saved": "Salvato.", + "error": "Non è stato possibile salvare. Riprova.", + "loadFailed": "Non è stato possibile caricare la tua selezione.", + "eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.", + "omitted": "{count} pilastri non compaiono perché il loro monitoraggio è disattivato. Riattiva un modulo e il suo pilastro torna.", + "omittedLink": "Apri i moduli", + "refusal": { + "physiological": "Un punteggio di salute ha bisogno di almeno una misurazione fisica. Tieni nella selezione la pressione, la glicemia, il sonno, la forma corporea o il colesterolo, oltre a ciò che scegli d'altro.", + "domains": "Un punteggio di salute ha bisogno di almeno tre ambiti di salute diversi. Pressione, glicemia e colesterolo descrivono lo stesso ambito, quindi una selezione fatta solo di questi conta come uno." + }, + "notice": { + "upgrade": { + "title": "Ora sei tu a decidere cosa conta", + "body": "Quali pilastri contassero nel tuo Health Score dipendeva prima dai moduli che avevi attivi. Ora è una scelta a parte, e la fai qui. Il tuo numero non si è mosso con questo cambiamento: conta ancora esattamente quello che contava prima." + }, + "changed": { + "title": "Hai cambiato cosa conta", + "body": "Dal {date} il tuo punteggio è calcolato su un insieme di pilastri diverso. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni.", + "bodyUndated": "Il tuo punteggio è calcolato su un insieme di pilastri diverso da prima. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni." + }, + "dismiss": "Ho capito", + "dismissError": "Non è stato possibile salvare. Riprova." + } } }, "profile": "Profilo", diff --git a/messages/pl.json b/messages/pl.json index bd8eda6a0..df0a5c772 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,7 +2268,7 @@ "yellow": "Niektóre filary wymagają uwagi", "red": "Co najmniej jeden filar jest poza zakresem referencyjnym" }, - "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", + "insufficient": "{count} z {required} obszarów zdrowia ma wystarczająco świeżych danych. Wynik potrzebuje {required}, w tym co najmniej jednego pomiaru fizycznego.", "anatomyToggle": "Jak powstaje ten wynik", "notScored": "Jeszcze bez oceny ({count})", "method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.", @@ -2348,6 +2348,11 @@ "weight_not_tracked": "Zapisz pomiar masy ciała, aby zobaczyć postęp w kierunku celu.", "read_failed": "Nie udało się wczytać danych masy ciała. Spróbuj ponownie.", "notScored": "Postęp celu wagowego jest osobistym kontekstem i nie wpływa na wynik zdrowia." + }, + "configureLink": "Wybierz, co liczy się do Twojego wyniku", + "coverage": { + "minimumMet": "Pokrywa trzy obszary wymagane przez wynik", + "areas": "{present} z {required} obszarów zdrowia" } }, "suggestedPrompts": { @@ -3609,7 +3614,8 @@ "summary": "{present} z {required} danych · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Na podstawie {count} dni historii", - "missing": "Zapisuj te dane, aby uściślić wartość: {list}" + "missing": "Zapisuj te dane, aby uściślić wartość: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Wynik {score} na 100, {band}", @@ -4898,6 +4904,68 @@ "fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).", "fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.", "fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}." + }, + "score": { + "title": "Wynik zdrowia", + "subtitle": "Wybierz, które filary liczą się do Twojego wyniku.", + "card": { + "title": "Co liczy się do Twojego wyniku", + "description": "Każdy filar, który zapisujesz, liczy się, dopóki nie powiesz tutaj inaczej." + }, + "threeAxes": "Zapisywanie, pokazywanie i liczenie to trzy różne rzeczy. Ta strona zmienia tylko ostatnią. Filar wyłączony tutaj nadal jest zapisywany i nadal widoczny w całym HealthLogu, przestaje jedynie ruszać liczbą.", + "axis": { + "recorded": "Jest zapisywany", + "shown": "Pokazywany w aplikacji", + "countsFor": "Licz {pillar} do wyniku" + }, + "domain": { + "cardiometabolic": "Serce i metabolizm", + "activity": "Aktywność", + "sleep": "Sen", + "adiposity": "Sylwetka", + "wellbeing": "Samopoczucie", + "fitness": "Zmierzona wydolność" + }, + "domainNote": { + "cardiometabolic": "Trzy filary, jeden obszar zdrowia. Każdy filar waży w wyniku tyle samo, więc ten obszar waży trzy razy tyle co obszar z jednym filarem." + }, + "state": { + "counting": "Liczy się do Twojego wyniku", + "waiting": "Wybrany, czeka na dane", + "crisis": "Zamiast wyniku pokazywana jest wskazówka bezpieczeństwa. Z Twoim wyborem wszystko w porządku.", + "readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu.", + "unavailable": "Jeszcze niedostępny", + "unavailableDetail": "HealthLog nie potrafi jeszcze odróżnić zmierzonego testu wydolności od szacunku z urządzenia, więc ten filar nigdy nie dał wyniku. Zostaje na liście, żebyś widział, że się nie liczy." + }, + "presets": { + "label": "Punkt wyjścia", + "all": "Wszystkie filary", + "current": "Twój zapisany wybór" + }, + "save": "Zapisz", + "saved": "Zapisano.", + "error": "Nie udało się zapisać. Spróbuj ponownie.", + "loadFailed": "Nie udało się wczytać Twojego wyboru.", + "eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.", + "omitted": "{count} filarów nie ma na liście, bo ich śledzenie jest wyłączone. Włącz moduł z powrotem, a jego filar wróci.", + "omittedLink": "Otwórz moduły", + "refusal": { + "physiological": "Wynik zdrowia potrzebuje co najmniej jednego pomiaru fizycznego. Zostaw w wyborze ciśnienie, glukozę, sen, sylwetkę albo cholesterol, obok wszystkiego innego, co wybierzesz.", + "domains": "Wynik zdrowia potrzebuje co najmniej trzech różnych obszarów zdrowia. Ciśnienie, glukoza i cholesterol opisują ten sam obszar, więc wybór złożony tylko z nich liczy się jako jeden." + }, + "notice": { + "upgrade": { + "title": "Teraz to Ty decydujesz, co się liczy", + "body": "To, które filary liczyły się do Twojego Health Score, szło wcześniej za modułami, które miałeś włączone. Teraz jest to osobna decyzja i podejmujesz ją tutaj. Twoja liczba się przy tym nie ruszyła: nadal liczy dokładnie to, co liczyła wcześniej." + }, + "changed": { + "title": "Zmieniłeś to, co się liczy", + "body": "Od {date} Twój wynik liczony jest z innego zestawu filarów. Dni wcześniejsze pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy.", + "bodyUndated": "Twój wynik liczony jest z innego zestawu filarów niż wcześniej. Dni sprzed zmiany pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy." + }, + "dismiss": "Rozumiem", + "dismissError": "Nie udało się zapisać. Spróbuj ponownie." + } } }, "profile": "Profil", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 9042a54b6..904500df1 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -24,6 +24,7 @@ import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { PrivacySection } from "@/components/settings/privacy-section"; +import { ScoreSection } from "@/components/settings/score-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; import { VorsorgeSection } from "@/components/settings/vorsorge-section"; // v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the @@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record< illness: IllnessSection, environment: EnvironmentSection, anamnesis: AnamnesisSection, + score: ScoreSection, vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 34a181398..1301de90d 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -597,9 +597,13 @@ describe(" composite states", () => { /data-slot="health-score-card"[^>]*data-status="insufficient"/, ); expect(html).toContain('data-slot="health-score-insufficient"'); + // `presentInputs` counts distinct DOMAINS. The sentence used to call + // them pillars, which told an account with blood pressure, glycaemia + // and lipids that it had one eligible pillar while it had three. expect(html).toContain( - "The score needs at least three, including a measured physiological pillar.", + "1 of 3 areas of health have enough recent data. The score needs 3, at least one of them a physical measurement.", ); + expect(html).not.toMatch(/eligible pillars/i); // No band sentence and no fabricated number: a dash, and no bar either. expect(html).not.toContain('data-slot="health-score-band"'); expect(html).not.toContain('data-slot="health-score-card-progress"'); diff --git a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx new file mode 100644 index 000000000..085688c61 --- /dev/null +++ b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx @@ -0,0 +1,273 @@ +/** + * Two claims the Health Score panel used to make about its own coverage + * that were not true, held down structurally so a tidy-up cannot bring + * either back. + * + * **1. `composite.coverage.presentInputs` counts DOMAINS, not pillars.** + * `computeComposite` builds it from `eligibleDomains.size`. The panel fed + * it into a sentence reading "{count} eligible pillars", so someone + * recording blood pressure, glycaemia and lipids was told they had one + * eligible pillar while looking at three. The count was right; the noun + * was wrong, and a noun is exactly the thing a later cleanup restores + * without noticing. The guard reads the call sites out of the AST, so it + * fails on the value AND on the copy — passing the domain count into a + * pillar-shaped string is caught wherever the string lives. + * + * **2. The composite's coverage fraction is a floor, not a proportion.** + * `requiredInputs` is the three-domain minimum and `deriveCoverage` clamps + * `presentInputs` to it, so every scored account rendered "3/3" forever, + * whatever it records. The arithmetic is right and stays untouched; what + * was wrong is presenting a met floor as full coverage of the person's own + * data. The composite's meter therefore names its axis, and the guard + * fails if that override is dropped. + * + * Both are AST matchers rather than text searches, and both carry a + * counter-test proving they stay quiet on the neighbouring call sites that + * are legitimately about pillars. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoverageMeter } from "../derived/coverage-meter"; +import en from "../../../../messages/en.json"; + +const CARD_FILE = join( + process.cwd(), + "src/components/insights/health-score-card.tsx", +); + +function parse(path: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + path, + text ?? readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); +} + +/** Dotted access path of an expression, or null when it is not one. */ +function accessPath(node: ts.Expression): string | null { + if (ts.isIdentifier(node)) return node.text; + if (ts.isPropertyAccessExpression(node)) { + const base = accessPath(node.expression); + return base === null ? null : `${base}.${node.name.text}`; + } + return null; +} + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + node.forEachChild((child) => walk(child, visit)); +} + +/** Look a dotted i18n key up in the English bundle. */ +function message(key: string): string | undefined { + let cursor: unknown = en; + for (const segment of key.split(".")) { + if (cursor === null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return typeof cursor === "string" ? cursor : undefined; +} + +/** + * Every `t("some.key", { … })` in `file` that interpolates a value read + * off `composite.coverage`, paired with the access paths it passed. + */ +function coverageBackedMessages( + file: ts.SourceFile, +): { key: string; paths: string[] }[] { + const found: { key: string; paths: string[] }[] = []; + walk(file, (node) => { + if (!ts.isCallExpression(node)) return; + if (!ts.isIdentifier(node.expression) || node.expression.text !== "t") { + return; + } + const [keyArg, valuesArg] = node.arguments; + if (!keyArg || !ts.isStringLiteral(keyArg)) return; + if (!valuesArg || !ts.isObjectLiteralExpression(valuesArg)) return; + const paths: string[] = []; + for (const property of valuesArg.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const path = accessPath(property.initializer); + if (path && path.startsWith("composite.coverage.")) paths.push(path); + } + if (paths.length > 0) found.push({ key: keyArg.text, paths }); + }); + return found; +} + +/** `` elements, with the attribute names each carries. */ +function coverageMeters( + file: ts.SourceFile, +): { coverage: string | null; attributes: string[] }[] { + const found: { coverage: string | null; attributes: string[] }[] = []; + walk(file, (node) => { + let opening: ts.JsxOpeningLikeElement | null = null; + if (ts.isJsxSelfClosingElement(node)) opening = node; + else if (ts.isJsxElement(node)) opening = node.openingElement; + if (!opening) return; + if (opening.tagName.getText(file) !== "CoverageMeter") return; + + const attributes: string[] = []; + let coverage: string | null = null; + for (const attribute of opening.attributes.properties) { + if (!ts.isJsxAttribute(attribute)) continue; + const name = attribute.name.getText(file); + attributes.push(name); + if (name !== "coverage") continue; + const initializer = attribute.initializer; + if ( + initializer && + ts.isJsxExpression(initializer) && + initializer.expression + ) { + coverage = accessPath(initializer.expression); + } + } + found.push({ coverage, attributes }); + }); + return found; +} + +/** + * The check itself, lifted out so the regression case below runs the very + * assertions the guard runs rather than an imitation of them. + */ +function checkDomainNoun(site: { key: string; paths: string[] }): void { + const copy = message(site.key); + expect(copy, `missing English copy for ${site.key}`).toBeTypeOf("string"); + expect( + copy, + `${site.key} interpolates ${site.paths.join(", ")} (a domain count) but reads as pillars`, + ).not.toMatch(/pillar/i); + expect(copy, `${site.key} should name the areas it counts`).toMatch(/area/i); +} + +describe("the domain count is never described as a pillar count", () => { + const sites = coverageBackedMessages(parse(CARD_FILE)); + + it("finds the call site at all", () => { + // A matcher that matches nothing passes every assertion below it. + expect(sites.length).toBeGreaterThan(0); + expect(sites.map((site) => site.key)).toContain( + "insights.healthScore.insufficient", + ); + }); + + it("gives every composite-coverage sentence a domain-shaped noun", () => { + for (const site of sites) checkDomainNoun(site); + }); + + it("stays quiet on a count that really is a count of pillars", () => { + // The neighbouring `notScored` line passes `notScored.length`, a real + // pillar count, into copy that says "pillars" and must keep saying so. + const counterExample = parse( + "counter-example.tsx", + `export function X() { + return

{t("insights.healthScore.notScored", { count: notScored.length })}

; + }`, + ); + expect(coverageBackedMessages(counterExample)).toEqual([]); + expect(message("insights.healthScore.notScored")).toMatch(/not scored/i); + }); + + it("catches the mislabel when it is reintroduced", () => { + // The guard's own failure mode, driven through the guard's own check: + // a domain count fed into a pillar-shaped key is collected, and the + // check on it throws. `reason.unavailable` is a real shipped string + // that says "pillar" for a good reason, which is what makes it the + // right stand-in for the copy this defect produces. + const regression = parse( + "regression.tsx", + `export function X() { + return

{t("insights.healthScore.reason.unavailable", { count: composite.coverage.presentInputs })}

; + }`, + ); + const collected = coverageBackedMessages(regression); + expect(collected).toHaveLength(1); + expect(() => checkDomainNoun(collected[0])).toThrow(/pillar/i); + }); +}); + +describe("the composite's coverage meter names its own axis", () => { + const meters = coverageMeters(parse(CARD_FILE)); + + it("finds every meter on the panel", () => { + // Three: the composite's, each scored pillar's, and the weight goal's. + expect(meters).toHaveLength(3); + }); + + it("overrides the fraction on the composite's meter", () => { + const composite = meters.filter( + (meter) => meter.coverage === "composite.coverage", + ); + expect(composite).toHaveLength(1); + expect(composite[0].attributes).toContain("axisLabel"); + }); + + it("leaves the per-pillar and weight-goal meters alone", () => { + // Their fractions are real: a pillar's coverage moves with its own + // inputs, so the default `{present}/{required}` is honest there. The + // guard must not spread the override to them. + const others = meters.filter( + (meter) => meter.coverage !== "composite.coverage", + ); + expect(others).toHaveLength(2); + for (const meter of others) { + expect(meter.attributes).not.toContain("axisLabel"); + } + }); + + it("has copy for both axis labels", () => { + expect(message("insights.healthScore.coverage.minimumMet")).toMatch( + /area/i, + ); + expect(message("insights.healthScore.coverage.areas")).toContain( + "{present}", + ); + }); +}); + +/** + * The attribute guard above proves the override is written down. This + * proves it does something: a prop that is declared and then ignored is + * exactly the shape of a check that cannot fail. + */ +describe("the axis override reaches the markup", () => { + const coverage = { + requiredInputs: 3, + presentInputs: 3, + historyDays: 20, + missing: [], + }; + + function render(axisLabel?: string): string { + return renderToStaticMarkup( + + + , + ); + } + + it("replaces the fraction in the label and the accessible name", () => { + const html = render("Covers the three areas the score needs"); + + expect(html).toContain("Covers the three areas the score needs"); + expect(html).not.toContain(">3/3<"); + expect(html).toMatch(/aria-label="Covers the three areas the score needs/); + }); + + it("keeps the fraction for every caller that does not override it", () => { + const html = render(); + + expect(html).toContain("3/3"); + expect(html).toContain("3 of 3 inputs"); + }); +}); diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx index 872abb2e5..c3b20e97a 100644 --- a/src/components/insights/derived/coverage-meter.tsx +++ b/src/components/insights/derived/coverage-meter.tsx @@ -44,6 +44,20 @@ export interface CoverageMeterProps { dots?: number; /** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */ size?: "sm" | "md"; + /** + * Replaces the bare `{present}/{required}` fraction, in the visible + * label, the `aria-label` and the tooltip heading alike. + * + * The default fraction reads as a proportion of the reader's own data, + * and for one caller it is not: the Health Score's composite counts + * DISTINCT DOMAINS against the three the score requires, and a scored + * account has met that floor by definition, so the fraction is pinned + * at "3/3" however much or little the person records. The dots and the + * percentage stay meaningful there because they track history depth; + * only the fraction lies. A caller in that position names its axis + * instead. Everyone else leaves this alone and keeps the fraction. + */ + axisLabel?: string; /** Optional className applied to the outer wrapper. */ className?: string; } @@ -75,6 +89,7 @@ export function CoverageMeter({ confidence, dots = 5, size = "sm", + axisLabel, className, }: CoverageMeterProps) { const { t } = useTranslations(); @@ -100,11 +115,16 @@ export function CoverageMeter({ const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2"; - const summaryLabel = t("insights.derived.coverage.summary", { - present, - required, - percent, - }); + const summaryLabel = axisLabel + ? t("insights.derived.coverage.summaryLabelled", { + label: axisLabel, + percent, + }) + : t("insights.derived.coverage.summary", { + present, + required, + percent, + }); return ( @@ -156,7 +176,11 @@ export function CoverageMeter({ size === "md" ? "text-xs" : "text-[11px]", )} > - {t("insights.derived.coverage.ratioLabel", { present, required })} + {axisLabel ?? + t("insights.derived.coverage.ratioLabel", { + present, + required, + })} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 5c7002728..f6f43969b 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useId, useState } from "react"; +import Link from "next/link"; import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -27,11 +28,11 @@ import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveGlucoseUnit } from "@/lib/glucose"; import type { HealthScoreReport, - ScorePillarId, ScoreBand, ScorePillarResult, } from "@/lib/analytics/score/types"; import { useTranslations } from "@/lib/i18n/context"; +import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels"; import { cn } from "@/lib/utils"; /** @@ -64,16 +65,12 @@ import { cn } from "@/lib/utils"; * composite's colour, which painted a pillar scoring 92 red on a red day. */ -const PILLAR_LABEL_KEY: Record = { - BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", - GLYCAEMIA: "insights.healthScore.pillar.glycaemia", - ACTIVITY: "insights.healthScore.pillar.activity", - SLEEP: "insights.healthScore.pillar.sleep", - ADIPOSITY: "insights.healthScore.pillar.adiposity", - WELLBEING: "insights.healthScore.pillar.wellbeing", - FITNESS: "insights.healthScore.pillar.fitness", - LIPIDS: "insights.healthScore.pillar.lipids", -}; +/** + * v1.35.0 — the map moved to `@/lib/score-config/labels` so the settings + * surface that decides what counts and this panel name the same pillar the + * same way. Aliased here because every reference below reads this name. + */ +const PILLAR_LABEL_KEY = SCORE_PILLAR_LABEL_KEYS; const REASON_KEY: Record = { read_failed: "readFailed", @@ -386,8 +383,16 @@ export function HealthScoreCard({ )} @@ -515,12 +520,30 @@ export function HealthScoreCard({ {/* 7 — the foot. `mt-auto` collects the slack here rather than under the number, so a short report and a long one both end on the same line. */}
+ {/* The composite's meter names its own axis. Its fraction counts + distinct DOMAINS against the three the score requires, and a + scored account has cleared that floor by definition, so the + default "3/3" would read as full coverage of the person's data + for everyone who has a score at all. Below the floor the + fraction is a real, moving number and is shown as one; at or + above it, the floor is stated instead. The dots and percentage + are untouched either way: they track history depth, which does + vary, and they are the part of this meter that was always + telling the truth. */} {/* Progressive disclosure. No Collapsible primitive exists in the UI @@ -624,6 +647,18 @@ export function HealthScoreCard({ standard={METRIC_PROVENANCE.HEALTH_SCORE.standard} className="block" /> + {/* v1.35.0 — the one way into the surface that decides what + counts. It sits at the foot of the disclosure that already + explains how the score comes about, which keeps the + configuration owned by the score rather than by the modules + screen, and leaves the panel itself otherwise untouched. */} + + {t("insights.healthScore.configureLink")} +
diff --git a/src/components/settings/__tests__/score-change-notice.test.tsx b/src/components/settings/__tests__/score-change-notice.test.tsx new file mode 100644 index 000000000..4c50ba780 --- /dev/null +++ b/src/components/settings/__tests__/score-change-notice.test.tsx @@ -0,0 +1,147 @@ +/** + * The Health Score notice, which had no renderer at all until this release. + * + * The machinery raised it, persisted the dismissal and evicted the cache for + * its whole life while nothing drew it, so the two things worth pinning are + * that each of its two shapes says the right thing and that it can be + * dismissed. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import { ScoreChangeNotice } from "../score-change-notice"; + +function render( + props: { + configVersion: number; + changedAt: string | null; + }, + locale: Locale = "en", +): string { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +describe("an account that has never chosen", () => { + it("explains what the upgrade did and that the number did not move", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).toContain('data-kind="upgrade"'); + expect(html).toContain("What counts is now yours to choose"); + expect(html).toContain("used to follow the modules you had switched on"); + expect(html).toContain("still counts exactly what it counted before"); + }); + + it("never claims a change the account has not made", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).not.toContain("You changed what counts"); + }); +}); + +describe("an account that changed its recipe", () => { + it("dates the change and says what it does to the history", () => { + const html = render({ + configVersion: 3, + changedAt: "2026-07-31T09:00:00.000Z", + }); + + expect(html).toContain('data-kind="changed"'); + expect(html).toContain("You changed what counts"); + expect(html).toContain("2026"); + expect(html).toContain("the weekly change stays paused"); + expect(html).toContain("Each pillar's own history runs straight"); + }); + + it("keeps the note truthful when the date cannot be read", () => { + const html = render({ configVersion: 3, changedAt: "not-a-date" }); + + expect(html).toContain('data-kind="changed"'); + expect(html).toContain("You changed what counts"); + // No invented date, and no literal placeholder left standing. + expect(html).not.toContain("{date}"); + expect(html).not.toContain("Invalid Date"); + }); +}); + +describe("dismissal", () => { + it("offers a dismiss control", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).toContain('data-slot="score-change-notice-dismiss"'); + expect(html).toContain("Got it"); + }); + + it("calls back with no argument when pressed", () => { + const onDismiss = vi.fn(); + // The button's own handler, exercised directly: `renderToStaticMarkup` + // has no events, and the parent's mutation is what carries the itemKey. + const node = ( + + ); + expect(node.props.onDismiss).toBe(onDismiss); + }); +}); + +describe("localisation", () => { + // Not an absence check: `t()` falls back to English, so a missing key + // renders English rather than a key path. These are each locale's own + // titles, so an English fallback, a copied value or a mis-wired bundle + // all fail here. Key parity across bundles is a repo-wide guard already. + const TITLES: Record = { + de: { + upgrade: "Was zählt, entscheidest jetzt du", + changed: "Du hast geändert, was zählt", + }, + fr: { + upgrade: "Ce qui compte, c'est vous qui le décidez", + changed: "Vous avez changé ce qui compte", + }, + es: { + upgrade: "Ahora decides tú qué cuenta", + changed: "Has cambiado qué cuenta", + }, + it: { + upgrade: "Ora sei tu a decidere cosa conta", + changed: "Hai cambiato cosa conta", + }, + pl: { + upgrade: "Teraz to Ty decydujesz, co się liczy", + changed: "Zmieniłeś to, co się liczy", + }, + }; + + it("renders both shapes in each locale's own words", () => { + for (const [locale, titles] of Object.entries(TITLES)) { + const upgrade = render( + { configVersion: 0, changedAt: null }, + locale as Locale, + ); + expect(upgrade, `${locale} upgrade note`).toContain( + // The apostrophe in the French title is escaped in the markup. + titles.upgrade.replace(/'/g, "'"), + ); + expect(upgrade).not.toContain("What counts is now yours to choose"); + + const changed = render( + { configVersion: 2, changedAt: "2026-07-31T09:00:00.000Z" }, + locale as Locale, + ); + expect(changed, `${locale} changed note`).toContain(titles.changed); + expect(changed).not.toContain("You changed what counts"); + } + }); +}); diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx new file mode 100644 index 000000000..85101c7ee --- /dev/null +++ b/src/components/settings/__tests__/score-pillar-row.test.tsx @@ -0,0 +1,169 @@ +/** + * The row states of the "what counts toward your score" surface, rendered. + * + * `rows.test.ts` pins the decisions; this pins that the decisions reach the + * markup, and that each row carries the stable attributes the e2e spec + * selects on rather than viewport-dependent text. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import type { ScoreConfigRow } from "@/lib/score-config/rows"; +import { ScorePillarRow } from "../score-pillar-row"; + +function render(row: ScoreConfigRow, locale: Locale = "en"): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +const base: ScoreConfigRow = { + id: "SLEEP", + domain: "sleep", + counts: true, + selectable: true, + eligibility: "counting", +}; + +describe("the three axes", () => { + it("names recording and showing beside the score switch", () => { + const html = render(base); + + expect(html).toContain('data-slot="score-pillar-axis-recorded"'); + expect(html).toContain('data-slot="score-pillar-axis-shown"'); + expect(html).toContain('data-slot="score-pillar-switch"'); + expect(html).toContain("Being recorded"); + expect(html).toContain("Shown in the app"); + }); + + it("offers exactly one switch, and it is the score one", () => { + const html = render(base); + + expect(html.match(/role="switch"/g) ?? []).toHaveLength(1); + expect(html).toContain("Count Sleep toward the score"); + }); +}); + +describe("row attributes", () => { + it("carries the pillar, its domain and its state", () => { + const html = render({ ...base, counts: false, eligibility: "waiting" }); + + expect(html).toContain('data-pillar="SLEEP"'); + expect(html).toContain('data-domain="sleep"'); + expect(html).toContain('data-counts="false"'); + expect(html).toContain('data-eligibility="waiting"'); + }); +}); + +describe("selected, waiting for data", () => { + it("renders the line for a counted pillar with nothing to score", () => { + const html = render({ ...base, counts: true, eligibility: "waiting" }); + + expect(html).toContain('data-state="waiting"'); + expect(html).toContain("Selected, waiting for data"); + }); + + it("does not render it once the person switches the pillar off", () => { + const html = render({ ...base, counts: false, eligibility: "waiting" }); + + expect(html).not.toContain("Selected, waiting for data"); + }); + + it("does not render it while the server has said nothing", () => { + const html = render({ ...base, eligibility: "unknown" }); + + expect(html).not.toContain('data-slot="score-pillar-state"'); + }); +}); + +describe("wellbeing under crisis signposting", () => { + it("is named as safety guidance, never as a configuration problem", () => { + const html = render({ + ...base, + id: "WELLBEING", + domain: "wellbeing", + eligibility: "crisis", + }); + + expect(html).toContain('data-state="crisis"'); + expect(html).toContain("Safety guidance is shown instead of a score"); + expect(html).toContain("Your selection is fine"); + expect(html).not.toContain("Selected, waiting for data"); + }); +}); + +describe("a failed read", () => { + it("is named as a failure, not as absence", () => { + const html = render({ ...base, eligibility: "read_failed" }); + + expect(html).toContain('data-state="read_failed"'); + expect(html).toContain("could not be read"); + expect(html).not.toContain("Selected, waiting for data"); + }); +}); + +describe("a pillar this build cannot score", () => { + it("is disabled, honest, and offers no switch at all", () => { + const html = render({ + ...base, + id: "FITNESS", + domain: "fitness", + selectable: false, + eligibility: "unavailable", + }); + + expect(html).toContain('data-selectable="false"'); + expect(html).toContain('data-slot="score-pillar-unavailable"'); + expect(html).toContain('aria-disabled="true"'); + expect(html).toContain("Not available yet"); + // Not a switch that no-ops behind a disabled attribute: no switch. + expect(html).not.toContain('data-slot="score-pillar-switch"'); + expect(html).not.toContain('role="switch"'); + }); + + it("says why, rather than leaving the row a dead end", () => { + const html = render({ + ...base, + id: "FITNESS", + domain: "fitness", + selectable: false, + eligibility: "unavailable", + }); + + expect(html).toContain("has never produced a score"); + }); +}); + +describe("localisation", () => { + // Asserting the absence of a key path would prove nothing: `t()` falls + // back to the English bundle, so a missing locale key renders English + // rather than the key. Locale PARITY is already a repo-wide guard + // (`i18n-locale-integrity`); what belongs here is that the row actually + // reaches each locale's own words, which fails on a missing key, on an + // English value copied into a bundle, and on a locale wired to the + // wrong strings. + const WAITING: Record = { + de: "Ausgewählt, wartet auf Daten", + fr: "Sélectionné, en attente de données", + es: "Seleccionado, esperando datos", + it: "Selezionato, in attesa di dati", + pl: "Wybrany, czeka na dane", + }; + + it("renders each locale's own words, not the English fallback", () => { + for (const [locale, expected] of Object.entries(WAITING)) { + const html = render( + { ...base, eligibility: "waiting" }, + locale as Locale, + ); + expect(html, `${locale} row`).toContain(expected); + expect(html, `${locale} row fell back to English`).not.toContain( + "Selected, waiting for data", + ); + } + }); +}); diff --git a/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts new file mode 100644 index 000000000..2a864de77 --- /dev/null +++ b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts @@ -0,0 +1,138 @@ +/** + * The write-time refusal has two ends, and this holds them together. + * + * The server owns the breadth rule and answers a too-narrow selection + * with a machine reason in `meta.reason`. The settings surface turns that + * reason into a sentence in the reader's own language. Nothing in the + * type system connects the two: rename a reason on the route, and the + * client silently falls through to "couldn't save, try again" while the + * person is left with no idea what is wrong with their selection. That is + * a refusal that stops refusing anything in particular, and it looks + * perfectly healthy in every unit test on either side. + * + * So: the reasons the rule can produce, the reasons the route has copy + * for, and the reasons the client has copy for must be the same set. The + * rule's own list is read out of `breadth.ts` rather than restated here, + * because a restated list is a fourth place to forget. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import en from "../../../../messages/en.json"; +import { REFUSAL_KEYS } from "../score-section"; + +const ROOT = process.cwd(); +const RULE_FILE = join(ROOT, "src/lib/analytics/score/breadth.ts"); +const ROUTE_FILE = join( + ROOT, + "src/app/api/auth/me/health-score-config/route.ts", +); + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); +} + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + node.forEachChild((child) => walk(child, visit)); +} + +/** The string members of an exported union type alias. */ +function unionMembers(file: ts.SourceFile, name: string): string[] { + const members: string[] = []; + walk(file, (node) => { + if (!ts.isTypeAliasDeclaration(node)) return; + if (node.name.text !== name) return; + const type = node.type; + const parts = ts.isUnionTypeNode(type) ? type.types : [type]; + for (const part of parts) { + if ( + ts.isLiteralTypeNode(part) && + ts.isStringLiteral(part.literal) && + part.literal.text.length > 0 + ) { + members.push(part.literal.text); + } + } + }); + return members; +} + +/** The property names of a top-level object-literal constant. */ +function objectKeys(file: ts.SourceFile, name: string): string[] { + const keys: string[] = []; + walk(file, (node) => { + if (!ts.isVariableDeclaration(node)) return; + if (!ts.isIdentifier(node.name) || node.name.text !== name) return; + let initializer = node.initializer; + while ( + initializer && + (ts.isAsExpression(initializer) || + ts.isSatisfiesExpression(initializer) || + ts.isParenthesizedExpression(initializer)) + ) { + initializer = initializer.expression; + } + if (!initializer || !ts.isObjectLiteralExpression(initializer)) return; + for (const property of initializer.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const propertyName = property.name; + if (ts.isIdentifier(propertyName)) keys.push(propertyName.text); + else if (ts.isStringLiteral(propertyName)) keys.push(propertyName.text); + } + }); + return keys; +} + +function message(key: string): string | undefined { + let cursor: unknown = en; + for (const segment of key.split(".")) { + if (cursor === null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return typeof cursor === "string" ? cursor : undefined; +} + +const ruleReasons = unionMembers(parse(RULE_FILE), "ScoreBreadthFailure"); +const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL"); + +describe("every refusal the rule can give reaches the reader", () => { + it("reads the rule's own list rather than a copy of it", () => { + // A matcher that finds nothing agrees with everything. + expect(ruleReasons.length).toBeGreaterThan(0); + expect(ruleReasons).toContain("three_domains_required"); + }); + + it("gives the route a sentence for each of them", () => { + expect([...routeReasons].sort()).toEqual([...ruleReasons].sort()); + }); + + it("gives the settings surface localised copy for each of them", () => { + expect(Object.keys(REFUSAL_KEYS).sort()).toEqual([...ruleReasons].sort()); + }); + + it("resolves every one of those keys to real copy", () => { + for (const [reason, key] of Object.entries(REFUSAL_KEYS)) { + const copy = message(key); + expect(copy, `${reason} → ${key}`).toBeTypeOf("string"); + expect(copy?.length ?? 0).toBeGreaterThan(20); + } + }); + + it("stays quiet on a constant that is not the refusal map", () => { + // The matcher is name-anchored, not shape-anchored: it must not pick + // up the neighbouring object literals on either file and quietly + // widen what the comparison above is comparing. + expect(objectKeys(parse(ROUTE_FILE), "NOT_A_REAL_CONSTANT")).toEqual([]); + expect(unionMembers(parse(RULE_FILE), "NotARealType")).toEqual([]); + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index b37d1a57a..8970f8ae0 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -121,6 +121,8 @@ describe("SETTINGS_SECTION_SLUGS", () => { "environment", // v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown. "anamnesis", + // v1.35.0 — the Health Score composition surface. + "score", "vorsorge", "thresholds", "ai", diff --git a/src/components/settings/score-change-notice.tsx b/src/components/settings/score-change-notice.tsx new file mode 100644 index 000000000..b7f995731 --- /dev/null +++ b/src/components/settings/score-change-notice.tsx @@ -0,0 +1,108 @@ +"use client"; + +/** + * The Health Score's "something about this number changed" note, rendered + * for the first time. + * + * The machinery behind it shipped a while ago and has been raising, + * persisting and evicting a notice that nothing ever drew: the report has + * carried `algorithmNotice` for its whole life and no surface read it. + * This is that renderer. It sits on the settings page rather than on the + * hero because the note's whole subject is what counts toward the score, + * and the person reading it is one tap from acting on it. + * + * Two notes ride one key, told apart by the account's own recipe version. + * + * - **Version 0** — nobody has chosen yet. The note explains what the + * upgrade did: what counts used to follow the module switches, it now + * follows this page, and the number itself did not move. It is the + * one-time note for accounts that existed before the choice did, and + * it is written to be true for a fresh account as well, because the + * stored recipe cannot tell the two apart and inventing a distinction + * from the account's age would be guessing. + * - **Version 1 and up** — the person changed what counts, on a date + * the recipe records. The note says what that does to the history. + * + * Dismissal is per key, so a dismissal of one version never silences the + * next, and it survives a reload because the server holds it. + */ + +import { Info, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +export interface ScoreChangeNoticeProps { + /** The account's own recipe version. 0 while nobody has chosen. */ + configVersion: number; + /** When the recipe last changed, ISO 8601, or null while it never has. */ + changedAt: string | null; + /** Fired when the person acknowledges the note. */ + onDismiss: () => void; + dismissing?: boolean; +} + +export function ScoreChangeNotice({ + configVersion, + changedAt, + onDismiss, + dismissing = false, +}: ScoreChangeNoticeProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const kind = configVersion >= 1 ? "changed" : "upgrade"; + const parsed = changedAt ? new Date(changedAt) : null; + const dateLabel = + parsed && !Number.isNaN(parsed.getTime()) ? fmt.date(parsed) : null; + + // Written out rather than assembled from the discriminator so the i18n + // call-site guard can see every key this component can reach. + const title = + kind === "changed" + ? t("settings.sections.score.notice.changed.title") + : t("settings.sections.score.notice.upgrade.title"); + const body = + kind !== "changed" + ? t("settings.sections.score.notice.upgrade.body") + : dateLabel + ? t("settings.sections.score.notice.changed.body", { date: dateLabel }) + : t("settings.sections.score.notice.changed.bodyUndated"); + + return ( + + +

+ {body} +

+ +
+ ); +} diff --git a/src/components/settings/score-pillar-row.tsx b/src/components/settings/score-pillar-row.tsx new file mode 100644 index 000000000..3dd1639b2 --- /dev/null +++ b/src/components/settings/score-pillar-row.tsx @@ -0,0 +1,134 @@ +"use client"; + +/** + * One pillar on the "what counts toward your Health Score" surface. + * + * The row's job is to make three things legible that nowhere else in the + * app shows together: whether the pillar is being recorded, whether it is + * shown, and whether it counts toward the score. Only the last is + * writable here, and the card above the rows says so in words. + * + * The first two are not switches on this surface and are not dressed to + * look like ones. They are read-outs, because the page that owns them is + * the modules screen and a second switch for the same fact is how two + * places end up disagreeing about one thing. + * + * Presentation only. The parent owns the draft, the mutation and the + * cache, so this stays a leaf a test can drive directly. + */ + +import { Loader2 } from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useTranslations } from "@/lib/i18n/context"; +import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels"; +import { + showsWaitingForData, + type ScoreConfigRow, +} from "@/lib/score-config/rows"; + +/** The eligibility values that earn a line of their own, and their copy. */ +const ELIGIBILITY_LINE_KEYS: Partial< + Record +> = { + counting: "settings.sections.score.state.counting", + crisis: "settings.sections.score.state.crisis", + read_failed: "settings.sections.score.state.readFailed", + unavailable: "settings.sections.score.state.unavailableDetail", +}; + +export interface ScorePillarRowProps { + row: ScoreConfigRow; + /** Disables the switch while a save is in flight. */ + pending?: boolean; + onToggle: (next: boolean) => void; +} + +export function ScorePillarRow({ + row, + pending = false, + onToggle, +}: ScorePillarRowProps) { + const { t } = useTranslations(); + const inputId = `score-pillar-${row.id}`; + // "Selected, waiting for data" is checked first because it is the one + // line that depends on the draft as well as on the server's verdict. + const stateKey = showsWaitingForData(row) + ? "settings.sections.score.state.waiting" + : ELIGIBILITY_LINE_KEYS[row.eligibility]; + + return ( +
+
+ + {/* The three axes, side by side. Recording and showing read as + settled facts; the third is the switch to the right. */} +

+ + {t("settings.sections.score.axis.recorded")} + + {" · "} + + {t("settings.sections.score.axis.shown")} + +

+ {stateKey ? ( +

+ {t(stateKey)} +

+ ) : null} +
+ +
+ {pending ? ( +
+
+ ); +} diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx new file mode 100644 index 000000000..f89c37ea2 --- /dev/null +++ b/src/components/settings/score-section.tsx @@ -0,0 +1,451 @@ +"use client"; + +/** + * `` — Settings → Health Score. v1.35.0. + * + * The surface that decides which pillars count toward the Health Score. + * It is its own page rather than a corner of the modules screen, because + * the two answer different questions and conflating them is what made the + * old behaviour invisible: the module switches quietly decided the + * score's composition while claiming to decide what is recorded and shown. + * + * What the page holds to: + * + * - **One concept, one card.** The rows, the starting points and the + * save all sit in the same card. A top half and a bottom half of the + * same decision is the split the settings IA rules forbid. + * - **Grouped by domain, and the grouping is visible.** Blood pressure, + * glycaemia and lipids sit under one heading with a sentence saying + * they are one area of health carrying three pillars. Equal weight per + * pillar is not equal weight per area, and until this page nothing in + * the app said so. + * - **Only the score toggle is writable.** Recording and showing are + * read-outs with a line saying this page leaves them alone. + * - **A pillar whose module is off is not shown.** Turning a module off + * closes the data path deliberately; a pillar behind a closed path is + * not waiting for anything, and saying it waits nags a person in the + * voice of their own settings. + * - **The refusal comes from the server.** The breadth rule lives in + * `evaluateScoreBreadth` and is applied at write time; this page shows + * the refusal in the reader's language and never re-derives the rule. + * A rule copied here would drift the day the server's moved. + * + * Two reads feed it. The configuration (`/api/auth/me/health-score-config`) + * is the primary one and its failure takes the surface down honestly. The + * analytics payload is the secondary one: it carries the server's own + * verdict on each pillar, which is where "selected, waiting for data" + * comes from. When that read has not landed the rows simply make no claim + * about data; when it fails, the failure is named rather than dressed as + * absence. + */ + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Gauge, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ScoreChangeNotice } from "@/components/settings/score-change-notice"; +import { ScorePillarRow } from "@/components/settings/score-pillar-row"; +import { markAlgorithmNoticeDismissed } from "@/components/insights/health-score-card"; +import { useAuth } from "@/hooks/use-auth"; +import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { + isConflict, + readUpdatedAtToken, + withBaseToken, +} from "@/lib/api/optimistic-token"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { + SCORE_DOMAIN_LABEL_KEYS, + SCORE_DOMAIN_NOTE_KEYS, +} from "@/lib/score-config/labels"; +import { + buildScoreConfigRows, + selectionToSave, + type ScorePillarVerdict, +} from "@/lib/score-config/rows"; +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; +import type { HealthScoreReport } from "@/lib/analytics/score/types"; +import type { ScoreModuleKey } from "@/lib/analytics/score/modules"; +import type { ScoreBreadthFailure } from "@/lib/analytics/score/breadth"; + +/** The resolved configuration `GET /api/auth/me/health-score-config` returns. */ +interface HealthScoreConfigPayload { + pillars: ScorePillarId[]; + excludedPillars: ScorePillarId[]; + hasSelection: boolean; + version: number; + changedAt: string | null; + /** Optimistic-concurrency token; absent until a selection exists. */ + updatedAt?: string; +} + +/** + * The two refusals the write can return, in the reader's language. + * + * Keyed on `meta.reason`, which is the machine-readable half of the + * server's answer. The server's own English sentence is not shown: it + * exists for callers without a locale, and rendering it would leave five + * of the six locales reading English at exactly the moment the person + * needs to understand something. + * + * Typed as a total map over `ScoreBreadthFailure` rather than over + * `string`, so a third refusal reason added to the rule cannot ship with + * this surface quietly falling through to a generic "couldn't save". The + * TYPE is imported, never the rule: the breadth question is answered + * server-side and answered once. + */ +export const REFUSAL_KEYS: Record = { + measured_physiological_domain_required: + "settings.sections.score.refusal.physiological", + three_domains_required: "settings.sections.score.refusal.domains", +}; + +function verdictsFrom( + report: HealthScoreReport | null | undefined, +): Partial> | undefined { + if (!report) return undefined; + const out: Partial> = {}; + for (const pillar of report.pillars) { + out[pillar.id] = + pillar.result.status === "ok" + ? { status: "ok" } + : { status: "insufficient", reason: pillar.result.reason }; + } + return out; +} + +/** Stable fingerprint of the server's selection, for the draft re-seed. */ +function selectionSignature(pillars: readonly ScorePillarId[]): string { + return SCORE_PILLAR_IDS.filter((id) => pillars.includes(id)).join(","); +} + +export function ScoreSection() { + const { t } = useTranslations(); + const { user, isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const configQuery = useQuery({ + queryKey: queryKeys.healthScoreConfig(), + queryFn: () => + apiGet("/api/auth/me/health-score-config"), + enabled: Boolean(isAuthenticated), + }); + + // The server's verdict on each pillar. Secondary: the switches work + // without it, so it never gates the surface. + const analyticsQuery = useAnalyticsQuery(); + const report = analyticsQuery.data?.healthScore as + HealthScoreReport | null | undefined; + const verdicts = useMemo(() => verdictsFrom(report), [report]); + + const serverSelection = useMemo( + () => configQuery.data?.pillars ?? [], + [configQuery.data?.pillars], + ); + const serverSignature = selectionSignature(serverSelection); + + // Re-seed the draft in render (the sanctioned "adjust state on prop + // change" shape, no effect) whenever the SERVER copy moves — a settled + // save, a refetch, or the first load. An in-flight edit is untouched + // until the server itself advances. + const [seededSignature, setSeededSignature] = useState(null); + const [draft, setDraft] = useState([]); + const [refusal, setRefusal] = useState(null); + if (configQuery.isSuccess && serverSignature !== seededSignature) { + setSeededSignature(serverSignature); + setDraft([...serverSelection]); + setRefusal(null); + } + + const modules = (user?.modules ?? {}) as Partial< + Record + >; + const { groups, omitted } = useMemo( + () => buildScoreConfigRows({ selection: draft, modules, verdicts }), + // `modules` is a fresh object each render; its identity is not the + // dependency, its content is. + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft, verdicts, user?.modules], + ); + + const dirty = selectionSignature(draft) !== serverSignature; + + const saveMutation = useMutation({ + mutationKey: queryKeys.healthScoreConfig(), + mutationFn: (selection: ScorePillarId[]) => + apiPatch( + "/api/auth/me/health-score-config", + withBaseToken( + { pillars: selectionToSave(selection) }, + readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()), + ), + ), + onSuccess: (saved) => { + setRefusal(null); + queryClient.setQueryData(queryKeys.healthScoreConfig(), saved); + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthScoreConfig(), + }); + // The number now means something else. Every surface that holds a + // computed score has to go, or the change reads as broken. + void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + toast.success(t("settings.sections.score.saved")); + }, + onError: (err) => { + if (isConflict(err)) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthScoreConfig(), + }); + toast.message(t("common.conflictReloaded")); + return; + } + // The write-time breadth refusal. The rule is the server's; this + // only translates the reason it gave. + if (err instanceof ApiError && err.status === 422) { + const reason = err.meta?.reason; + // The wire carries an unknown string until it is checked against + // the map; an unrecognised one falls through to the generic + // failure rather than rendering an empty alert. + const key = + typeof reason === "string" && reason in REFUSAL_KEYS + ? REFUSAL_KEYS[reason as ScoreBreadthFailure] + : undefined; + if (key) { + setRefusal(key); + return; + } + } + toast.error(t("settings.sections.score.error")); + }, + }); + + const dismissMutation = useMutation({ + mutationFn: async (itemKey: string) => { + await apiPost("/api/daily/digest/dismiss", { itemKey }); + return itemKey; + }, + onSuccess: (itemKey) => { + // Patch every cached copy of the notice, not just the one this page + // reads: the analytics payload carries the same raised notice and a + // stale `dismissed: false` there would raise it again on the next + // surface that learns to render it. + queryClient.setQueryData(queryKeys.analytics(), (previous: unknown) => + markAlgorithmNoticeDismissed(previous, itemKey), + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + }, + onError: () => { + toast.error(t("settings.sections.score.notice.dismissError")); + }, + }); + + function toggle(id: ScorePillarId, next: boolean) { + setRefusal(null); + setDraft((current) => { + const set = new Set(current); + if (next) set.add(id); + else set.delete(id); + return SCORE_PILLAR_IDS.filter((pillar) => set.has(pillar)); + }); + } + + const notice = report?.algorithmNotice ?? null; + const showNotice = + notice != null && !notice.dismissed && configQuery.isSuccess; + + const busy = saveMutation.isPending; + + return ( +
+ {showNotice && notice ? ( + dismissMutation.mutate(notice.itemKey)} + /> + ) : null} + + {configQuery.isError ? ( + void configQuery.refetch()} + /> + ) : ( + + saveMutation.mutate(draft)} + disabled={busy || !dirty || configQuery.isLoading} + data-slot="score-config-save" + className="min-h-11 sm:min-h-9" + > + {busy ? ( + + )} +
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index ccac49cd3..ae891f2ec 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -21,6 +21,7 @@ import { ClipboardList, FileHeart, FlaskConical, + Gauge, Info, KeyRound, Plug, @@ -61,6 +62,7 @@ const SLUG_ICON: Record = { illness: Thermometer, environment: CloudSun, anamnesis: ClipboardList, + score: Gauge, vorsorge: CalendarCheck, thresholds: SlidersHorizontal, ai: Sparkles, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 576964410..0f43f0b8a 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -78,6 +78,12 @@ export const SETTINGS_SECTION_SLUGS = [ // home for allergies + family history. Always shown (not module-gated, like // `vorsorge`); the records are foundational health-profile data. "anamnesis", + // v1.35.0 — "Health Score": which pillars count toward the composite. + // Its own surface rather than a corner of the modules hub, because the + // module switches answer a different question (what is recorded and + // shown) and folding the two together is what kept the score's + // composition invisible. + "score", "vorsorge", "thresholds", "ai", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 2c5cafc26..7bbe37097 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -25,6 +25,7 @@ import { CloudSun, Download, FileHeart, + Gauge, Info, KeyRound, Plug, @@ -234,6 +235,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ group: "display", }, // ── Health profile ── + // v1.35.0 — which pillars count toward the Health Score. Sits at the top + // of the health-profile cluster: it is a statement about the person's own + // health picture, not about how the app looks or what it records. + { + slug: "score", + titleKey: "settings.sections.score.title", + icon: Gauge, + group: "healthProfile", + }, // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record // home for allergies + family history. NOT module-gated (like Vorsorge) — // these are foundational health-profile records, always available. diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index f5975ae0f..e923c2656 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -38,6 +38,15 @@ export const settingsKeys = { */ modulesPrefs: () => ["settings", "modules"] as const, + /** + * v1.35.0 — which pillars count toward the Health Score, behind + * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than + * a slice of `authMe()`: the payload carries the optimistic-concurrency + * token the save echoes, and folding it into the account payload would + * make every unrelated profile write advance the token. + */ + healthScoreConfig: () => ["settings", "health-score-config"] as const, + apiVersion: () => ["api", "version"] as const, publicVersion: () => ["public", "version"] as const, /** v1.15.12 H1 — admin overview "update available" check against the diff --git a/src/lib/score-config/__tests__/rows.test.ts b/src/lib/score-config/__tests__/rows.test.ts new file mode 100644 index 000000000..0c2e9df96 --- /dev/null +++ b/src/lib/score-config/__tests__/rows.test.ts @@ -0,0 +1,299 @@ +/** + * What the "what counts toward your score" surface shows, and what it + * refuses to show. + * + * Four decisions are pinned here because each of them is a sentence a + * person reads about their own health, and each has a plausible wrong + * version that would look fine in a screenshot: + * + * 1. A pillar every feeding module has switched off gets NO row. Not a + * greyed row, not a "waiting for data" line. It is not waiting for + * anything, and under a never-written recipe the word "selected" + * would describe a default nobody picked. + * 2. "Selected, waiting for data" belongs to a pillar the person + * counts, whose module is on, and which the SERVER said has nothing + * to score. Not to a pillar the person just switched off, and not to + * one the server never mentioned. + * 3. Wellbeing under crisis signposting is its own state and never a + * configuration problem. + * 4. A pillar this build can never score is never sent as part of a + * selection, because the write-time breadth rule would otherwise + * accept a recipe on its behalf that the scorer then refuses. + */ +import { describe, expect, it } from "vitest"; + +import { + buildScoreConfigRows, + selectionToSave, + showsWaitingForData, + UNSCORABLE_PILLARS, + type ScoreConfigRow, +} from "@/lib/score-config/rows"; +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; + +const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS]; +const ALL_MODULES_ON = { + glucose: true, + labs: true, + sleep: true, + mentalHealth: true, +}; + +function rowsOf(result: ReturnType) { + return result.groups.flatMap((group) => group.rows); +} + +function row( + result: ReturnType, + id: ScorePillarId, +): ScoreConfigRow | undefined { + return rowsOf(result).find((candidate) => candidate.id === id); +} + +describe("grouping", () => { + it("puts blood pressure, glycaemia and lipids under one domain", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + const cardio = built.groups.find( + (group) => group.domain === "cardiometabolic", + ); + expect(cardio).toBeDefined(); + expect(cardio?.rows.map((r) => r.id)).toEqual([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "LIPIDS", + ]); + }); + + it("gives every other pillar a domain of its own", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + for (const group of built.groups) { + if (group.domain === "cardiometabolic") continue; + expect(group.rows).toHaveLength(1); + } + // Six domains across eight pillars is the whole reason the surface + // groups: equal weight per pillar is not equal weight per domain. + expect(built.groups).toHaveLength(6); + expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length); + }); + + it("emits no group for a domain whose only pillar is hidden", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, sleep: false }, + }); + + expect(built.groups.some((group) => group.domain === "sleep")).toBe(false); + }); +}); + +describe("a module that is off", () => { + it("removes the pillar entirely rather than showing it as waiting", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, sleep: false }, + verdicts: { SLEEP: { status: "insufficient", reason: "stale" } }, + }); + + expect(row(built, "SLEEP")).toBeUndefined(); + expect(built.omitted).toContain("SLEEP"); + }); + + it("keeps glycaemia while either of its two modules is on", () => { + const glucoseOnly = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, labs: false }, + }); + const labsOnly = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, glucose: false }, + }); + + expect(row(glucoseOnly, "GLYCAEMIA")).toBeDefined(); + expect(row(labsOnly, "GLYCAEMIA")).toBeDefined(); + // Lipids ride on labs alone, so they leave with it. + expect(row(glucoseOnly, "LIPIDS")).toBeUndefined(); + expect(row(labsOnly, "LIPIDS")).toBeDefined(); + }); + + it("hides glycaemia only when both of its modules are off", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, glucose: false, labs: false }, + }); + + expect(row(built, "GLYCAEMIA")).toBeUndefined(); + expect(built.omitted).toEqual(["GLYCAEMIA", "LIPIDS"]); + }); + + it("reads a missing module key as on", () => { + const built = buildScoreConfigRows({ selection: ALL, modules: {} }); + + expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length); + expect(built.omitted).toEqual([]); + }); + + it("never omits a pillar no module feeds", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { + glucose: false, + labs: false, + sleep: false, + mentalHealth: false, + }, + }); + + for (const id of [ + "BLOOD_PRESSURE", + "ACTIVITY", + "ADIPOSITY", + "FITNESS", + ] as ScorePillarId[]) { + expect(row(built, id)).toBeDefined(); + } + }); +}); + +describe("selected, waiting for data", () => { + it("is shown for a counted pillar the server had nothing to score", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } }, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.eligibility).toBe("waiting"); + expect(showsWaitingForData(lipids!)).toBe(true); + }); + + it("is not shown for a pillar the draft has switched off", () => { + const built = buildScoreConfigRows({ + selection: ALL.filter((id) => id !== "LIPIDS"), + modules: ALL_MODULES_ON, + verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } }, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.counts).toBe(false); + expect(showsWaitingForData(lipids!)).toBe(false); + }); + + it("is not shown while the server has said nothing", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.eligibility).toBe("unknown"); + expect(showsWaitingForData(lipids!)).toBe(false); + }); + + it("is not shown for a pillar that scored", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { SLEEP: { status: "ok" } }, + }); + + const sleep = row(built, "SLEEP"); + expect(sleep?.eligibility).toBe("counting"); + expect(showsWaitingForData(sleep!)).toBe(false); + }); +}); + +describe("states that are not absence", () => { + it("keeps crisis signposting out of the waiting state", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { + WELLBEING: { + status: "insufficient", + reason: "crisis_signposting", + }, + }, + }); + + const wellbeing = row(built, "WELLBEING"); + expect(wellbeing?.eligibility).toBe("crisis"); + expect(showsWaitingForData(wellbeing!)).toBe(false); + }); + + it("keeps a failed read out of the waiting state", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { + BLOOD_PRESSURE: { status: "insufficient", reason: "read_failed" }, + }, + }); + + const bp = row(built, "BLOOD_PRESSURE"); + expect(bp?.eligibility).toBe("read_failed"); + expect(showsWaitingForData(bp!)).toBe(false); + }); +}); + +describe("a pillar this build cannot score", () => { + it("is present, honest and never selectable", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + // Even a verdict claiming it scored cannot make it selectable. + verdicts: { FITNESS: { status: "ok" } }, + }); + + const fitness = row(built, "FITNESS"); + expect(fitness).toBeDefined(); + expect(fitness?.selectable).toBe(false); + expect(fitness?.eligibility).toBe("unavailable"); + }); + + it("leaves every other pillar selectable", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + for (const candidate of rowsOf(built)) { + expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id)); + } + }); + + it("is never sent as part of a saved selection", () => { + expect(selectionToSave(ALL)).not.toContain("FITNESS"); + expect(selectionToSave(["FITNESS"])).toEqual([]); + }); + + it("cannot carry a selection over the breadth floor on its own", () => { + // The failure this guards: FITNESS is a domain of its own AND counts + // as physiological, so echoing it back would let the write-time rule + // accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of + // them physiological — for a score the reader then refuses, because + // FITNESS never reaches the composition. + expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([ + "ACTIVITY", + "WELLBEING", + ]); + }); + + it("keeps registry order and drops duplicates", () => { + expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([ + "ACTIVITY", + "LIPIDS", + ]); + }); +}); diff --git a/src/lib/score-config/labels.ts b/src/lib/score-config/labels.ts new file mode 100644 index 000000000..31580daff --- /dev/null +++ b/src/lib/score-config/labels.ts @@ -0,0 +1,44 @@ +/** + * The localised names of the score's pillars and the domains they sit in. + * + * One map per concept, read by the hero panel and by the settings surface + * that decides what counts. Two copies of a name map drift the moment one + * surface renames a pillar, and the two surfaces would then be talking + * about different things while showing the same list. + */ +import type { ScoreDomain, ScorePillarId } from "@/lib/analytics/score/types"; + +export const SCORE_PILLAR_LABEL_KEYS: Record = { + BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", + GLYCAEMIA: "insights.healthScore.pillar.glycaemia", + ACTIVITY: "insights.healthScore.pillar.activity", + SLEEP: "insights.healthScore.pillar.sleep", + ADIPOSITY: "insights.healthScore.pillar.adiposity", + WELLBEING: "insights.healthScore.pillar.wellbeing", + FITNESS: "insights.healthScore.pillar.fitness", + LIPIDS: "insights.healthScore.pillar.lipids", +}; + +/** + * Domain headings. The cardiometabolic one carries three pillars, which + * is the whole reason the surface groups at all: equal weight per pillar + * is not equal weight per area, and nothing in the app said so before. + */ +export const SCORE_DOMAIN_LABEL_KEYS: Record = { + cardiometabolic: "settings.sections.score.domain.cardiometabolic", + activity: "settings.sections.score.domain.activity", + sleep: "settings.sections.score.domain.sleep", + adiposity: "settings.sections.score.domain.adiposity", + wellbeing: "settings.sections.score.domain.wellbeing", + fitness: "settings.sections.score.domain.fitness", +}; + +/** + * Domains that need a sentence under the heading. Only the one that + * carries more than one pillar does, and it is not decoration: without it + * the triple count stays invisible and deselecting one of the three + * reshapes the balance with no cue. + */ +export const SCORE_DOMAIN_NOTE_KEYS: Partial> = { + cardiometabolic: "settings.sections.score.domainNote.cardiometabolic", +}; diff --git a/src/lib/score-config/rows.ts b/src/lib/score-config/rows.ts new file mode 100644 index 000000000..45f0d8250 --- /dev/null +++ b/src/lib/score-config/rows.ts @@ -0,0 +1,221 @@ +/** + * v1.35.0 — what the "what counts toward your score" surface shows, as a + * pure function over three inputs the page already holds. + * + * Kept out of the component so the row states can be driven directly by a + * test instead of through a rendered tree, and so the page has exactly one + * place that decides what a row says. + * + * The three inputs, and why each is needed: + * + * - **the draft selection** — what the person currently has switched on + * in front of them, which is not yet what the server holds; + * - **the modules map** — whether the pillar's data is being recorded at + * all. A pillar every feeding module has switched off is not shown, + * full stop (see `omitted`, and `composition-source.test.ts`, which + * pins the same decision one layer down); + * - **the server's own pillar verdicts** — whether a pillar currently + * scores, is waiting for data, is suppressed for safety, or could not + * be read. These are never re-derived here. A pillar the server said + * nothing about reads as `unknown`, and `unknown` renders no claim. + * + * The eligibility a row shows describes the recipe the SERVER scored, not + * the draft in front of the person. That is deliberate: the draft has not + * been computed against anything, so a line derived from it would be a + * guess wearing the voice of a fact. + */ +import { + SCORE_PILLAR_DOMAINS, + SCORE_PILLAR_IDS, + type ScoreDomain, + type ScorePillarId, +} from "@/lib/analytics/score/types"; +import { + pillarsWithModuleData, + type ScoreModuleKey, + type ScoreReaderModules, +} from "@/lib/analytics/score/modules"; + +/** + * Pillars this build can never score, whatever anyone selects. + * + * FITNESS is the only entry and it is not a placeholder for a future + * list. The schema records VO₂max but cannot distinguish a measured test + * from a device estimate, so every FITNESS row reaches the scorer with + * `measured: false` and is refused (`src/lib/analytics/score/reader.ts`, + * the FITNESS input block). Offering it as a switch would be chrome over + * a thing that recomputes nothing — the exact defect this release exists + * to remove — so the row is present, honest, and inert. + */ +export const UNSCORABLE_PILLARS: ReadonlySet = new Set([ + "FITNESS", +]); + +/** + * What the server's last scoring run said about one pillar. Every value + * comes from a `ScorePillarResult`; nothing here is computed from data. + */ +export type ScoreRowEligibility = + /** It scored. */ + | "counting" + /** Selected, its data path is open, and there is nothing to score yet. */ + | "waiting" + /** Wellbeing under crisis signposting. Never a configuration problem. */ + | "crisis" + /** The read broke. Absence and failure are different states. */ + | "read_failed" + /** This build cannot score it at all. */ + | "unavailable" + /** The server said nothing about it. No claim is made either way. */ + | "unknown"; + +export interface ScoreConfigRow { + id: ScorePillarId; + domain: ScoreDomain; + /** True when the draft counts this pillar toward the score. */ + counts: boolean; + /** False when the switch is inert — today only the unscorable pillars. */ + selectable: boolean; + eligibility: ScoreRowEligibility; +} + +export interface ScoreConfigGroup { + domain: ScoreDomain; + rows: ScoreConfigRow[]; +} + +/** + * One pillar's verdict as the page reads it off the report: the status, + * plus the reason when the status is `insufficient`. + */ +export interface ScorePillarVerdict { + status: "ok" | "insufficient"; + reason?: string | null; +} + +export interface BuildScoreConfigRowsInput { + /** The draft selection the switches currently show. */ + selection: readonly ScorePillarId[]; + /** Resolved module map. A missing key reads as ON (fail open). */ + modules: Partial>; + /** + * Per-pillar verdicts from the last scoring run, keyed by pillar id. + * A pillar the server did not report on is simply absent. + */ + verdicts?: Partial>; +} + +/** Fail-open module resolution, matching every other gate in the app. */ +function resolveModules( + modules: Partial>, +): ScoreReaderModules { + return { + glucose: modules.glucose !== false, + labs: modules.labs !== false, + sleep: modules.sleep !== false, + mentalHealth: modules.mentalHealth !== false, + }; +} + +function eligibilityOf( + id: ScorePillarId, + verdict: ScorePillarVerdict | undefined, +): ScoreRowEligibility { + if (UNSCORABLE_PILLARS.has(id)) return "unavailable"; + if (!verdict) return "unknown"; + if (verdict.status === "ok") return "counting"; + if (verdict.reason === "crisis_signposting") return "crisis"; + if (verdict.reason === "read_failed") return "read_failed"; + return "waiting"; +} + +/** + * The pillars whose data path is closed, so the surface omits them. + * + * Returned beside the groups rather than silently dropped, because a + * caller that wants to say where those pillars went (the modules screen) + * needs to know there were any. + */ +export interface ScoreConfigRowsResult { + groups: ScoreConfigGroup[]; + /** Registry-ordered pillars hidden because every feeding module is off. */ + omitted: ScorePillarId[]; +} + +/** + * Build the surface's rows, grouped by domain in registry order. + * + * A domain with no visible rows produces no group, so a heading can never + * stand above an empty cluster. + */ +export function buildScoreConfigRows( + input: BuildScoreConfigRowsInput, +): ScoreConfigRowsResult { + const recorded = new Set( + pillarsWithModuleData(resolveModules(input.modules)), + ); + const selected = new Set(input.selection); + const groups: ScoreConfigGroup[] = []; + const byDomain = new Map(); + const omitted: ScorePillarId[] = []; + + for (const id of SCORE_PILLAR_IDS) { + if (!recorded.has(id)) { + omitted.push(id); + continue; + } + const domain = SCORE_PILLAR_DOMAINS[id]; + const row: ScoreConfigRow = { + id, + domain, + counts: selected.has(id), + selectable: !UNSCORABLE_PILLARS.has(id), + eligibility: eligibilityOf(id, input.verdicts?.[id]), + }; + const existing = byDomain.get(domain); + if (existing) { + existing.push(row); + } else { + const rows = [row]; + byDomain.set(domain, rows); + groups.push({ domain, rows }); + } + } + + return { groups, omitted }; +} + +/** + * Whether a row earns the "selected, waiting for data" line. + * + * Narrow on purpose, and the narrowing is the decision: the line belongs + * to a pillar the person counts, whose module is on (which is what makes + * the row visible at all), and which the server reported as having + * nothing to score yet. A pillar whose module is off never reaches here + * because it has no row; a pillar the draft excludes is not waiting for + * anything on the person's behalf; and an `unknown` verdict is not + * evidence of absence. + */ +export function showsWaitingForData(row: ScoreConfigRow): boolean { + return row.counts && row.eligibility === "waiting"; +} + +/** + * The selection a save should send. + * + * Every pillar the person can actually choose, and nothing else. A pillar + * this build cannot score is left out rather than echoed back: echoing it + * would let it satisfy the write-time breadth rule (it is a domain of its + * own AND a physiological pillar) on behalf of a score it can never + * reach, so the write would be accepted and the score would still + * disappear at read time. The refusal is only worth having if it judges + * the recipe that will actually be scored. + */ +export function selectionToSave( + selection: readonly ScorePillarId[], +): ScorePillarId[] { + const selected = new Set(selection); + return SCORE_PILLAR_IDS.filter( + (id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id), + ); +}