diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1634c89f7..e31579f6f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28110,7 +28110,6 @@ components: - SLEEP - ADIPOSITY - WELLBEING - - FITNESS - LIPIDS configured: description: "True when the account's own recipe narrows the score's composition below what its defaults would resolve @@ -28145,7 +28144,6 @@ components: - SLEEP - ADIPOSITY - WELLBEING - - FITNESS - LIPIDS - type: "null" restMode: diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 9d77e0bd3..9909ead1f 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -133,44 +133,35 @@ test.describe("Settings → Health Score", () => { 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. + // The rows say nothing about recording or showing — the card says it + // once, above them, and the modules screen owns those two questions. 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); + expect(await rows.count()).toBeGreaterThan(0); + await expect(page.locator('[data-slot="score-pillar-axes"]')).toHaveCount( + 0, + ); 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, - }) => { + test("offers a switch on every pillar it lists", 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); + const rows = page.locator('[data-slot="score-pillar-row"]'); + await settleBeforeMeasure(page, rows.first()); - 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`. + // Nothing on this page is a choice the person cannot make. The pillar + // that could never score was removed from the catalogue rather than + // listed as an inert row. + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + await expect(page.locator('[data-slot="score-pillar-switch"]')).toHaveCount( + rowCount, + ); await expect( - fitness.locator('[data-slot="score-pillar-switch"]'), + page.locator('[data-slot="score-pillar-row"][data-pillar="FITNESS"]'), ).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 ({ @@ -247,9 +238,7 @@ test.describe("Settings → Health Score", () => { // 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"]', - ); + const rows = page.locator('[data-slot="score-pillar-row"]'); for (let index = 0; index < (await rows.count()); index += 1) { const row = rows.nth(index); const pillar = await row.getAttribute("data-pillar"); diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index dbc70a054..d045ce489 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -12,15 +12,21 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; * * The spec drives that end-to-end against the real read path: * - * 1. load the dashboard with nothing fresh — no chip, day is provisional; - * 2. land last night's sleep through the REAL batch endpoint, and the - * arrival marker the spine's worker would have written; + * 1. load the dashboard with last night's sleep still missing; + * 2. land it through the REAL batch endpoint, and the arrival marker the + * spine's worker would have written; * 3. surface the tab's real visibility-change signal — the browser event * TanStack Query's focus manager consumes for * `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll); * using that existing trigger keeps the test inside the real cadence * while staying well under the poll interval; - * 4. assert the chip appeared and the phase flipped — in place, no reload. + * 4. assert the phase flipped and the pending note cleared — in place, no + * reload. + * + * The hero used to carry a "just in" chip here; it is gone, because under a + * number "just in" never said what had arrived. What the milestone was + * always about survives it: the open page reads differently on its own + * cadence. * * The marker is seeded directly rather than waited for from pg-boss: whether * the queue drains inside the e2e web server is the SPINE's contract and has @@ -43,9 +49,7 @@ function localDayKey(at: Date, timeZone: string): string { }).format(at); } -test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({ - page, -}) => { +test("a fresh arrival flips the day in place", async ({ page }) => { const dbUrl = process.env.DATABASE_URL; test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker"); @@ -85,10 +89,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy ); // ── BEFORE ──────────────────────────────────────────────────────────── - // Wait until the digest settles, then prove there is no stale arrival - // reaction. Other browser specs deliberately reuse this account and may - // have recorded older health data already, so an ordinary briefing Hero - // is a valid baseline; the invariant here is that it has no just-in chip. + // Wait until the digest settles. Other browser specs deliberately reuse + // this account and may have recorded older health data already, so an + // ordinary briefing hero is a valid baseline. Nothing on it may name an + // arrival: that chip was removed. await page.goto("/"); await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( 0, @@ -149,9 +153,6 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy window.dispatchEvent(new Event("visibilitychange")), ); - await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({ - timeout: 20_000, - }); await expect(hero).toHaveAttribute("data-phase", "final", { timeout: 20_000, }); @@ -159,10 +160,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy await expect( hero.locator('[data-slot="today-hero-sleep-pending"]'), ).toHaveCount(0); - // The chip names the arrival kind — the stable attribute, not the copy. - await expect( - hero.locator('[data-slot="today-hero-just-in"]'), - ).toHaveAttribute("data-just-in-kind", "sleep_night"); + // And the arrival still raises no chip of its own, on either pass. + await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount( + 0, + ); } finally { client.release(); await pool.end(); diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 51baace4a..266efdc2d 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -181,7 +181,6 @@ const HEALTH_SCORE = { scorePillar("ACTIVITY", "activity", 88), scorePillar("SLEEP", "sleep", 41), scorePillar("ADIPOSITY", "adiposity", 72), - gatedPillar("FITNESS", "fitness", "read_failed"), gatedPillar("LIPIDS", "cardiometabolic", "read_failed"), gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"), gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"), diff --git a/messages/de.json b/messages/de.json index 7a6964ab4..301b630f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2295,7 +2295,6 @@ "sleep": "Schlaf", "adiposity": "Körperfettverteilung", "wellbeing": "Wohlbefinden", - "fitness": "Gemessene Fitness", "lipids": "Blutfette" }, "observed": "Beobachtet: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} Schritte pro Tag", "observedSleep": "{value} Minuten pro Nacht", "observedAdiposity": "Taille-zu-Körpergröße-Verhältnis {value}", - "observedFitness": "{value} {unit}, gemessen", "referenceActivity": "Der Nutzen flacht bei {high} Schritten pro Tag ab", "referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet", "referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}", - "referenceFitness": "{low} bis {high} {unit}, Referenzbereich nach Alter und Geschlecht", "referenceHba1c": "HbA1c {low} bis {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} bis {high}", "openBound": "offen", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Wohlbefinden" }, "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." + "readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden." }, "presets": { "label": "Ausgangspunkt", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", - "justIn": "Gerade eingetroffen", - "justInAt": "Gerade eingetroffen · {time}", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen" diff --git a/messages/en.json b/messages/en.json index 85efbc250..50500e27f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2295,7 +2295,6 @@ "sleep": "Sleep", "adiposity": "Adiposity", "wellbeing": "Wellbeing", - "fitness": "Measured fitness", "lipids": "Lipids" }, "observed": "Observed: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} steps per day", "observedSleep": "{value} minutes per night", "observedAdiposity": "Waist-to-height ratio {value}", - "observedFitness": "{value} {unit}, measured", "referenceActivity": "Benefit plateaus by {high} steps per day", "referenceSleep": "At least {low} minutes per night; longer sleep is not penalised", "referenceAdiposity": "Waist-to-height ratio below {high}", - "referenceFitness": "{low} to {high} {unit}, age and sex reference band", "referenceHba1c": "HbA1c {low} to {high}%", "referenceDetail": "{label} · {source} · {kind} · {low} to {high}", "openBound": "open", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Wellbeing" }, "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." + "readFailed": "This pillar could not be read on the last run." }, "presets": { "label": "Start from", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} vs your baseline", "deltaFlat": "In line with your baseline", "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", - "justIn": "Just in", - "justInAt": "Just in · {time}", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", "ringLink": "Open {metric} details" diff --git a/messages/es.json b/messages/es.json index b008bd088..602833c88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2295,7 +2295,6 @@ "sleep": "Sueño", "adiposity": "Adiposidad", "wellbeing": "Bienestar", - "fitness": "Forma física medida", "lipids": "Lípidos" }, "observed": "Observado: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} pasos al día", "observedSleep": "{value} minutos por noche", "observedAdiposity": "Relación cintura-altura {value}", - "observedFitness": "{value} {unit}, medido", "referenceActivity": "El beneficio se estabiliza en {high} pasos al día", "referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza", "referenceAdiposity": "Relación cintura-altura inferior a {high}", - "referenceFitness": "{low} a {high} {unit}, intervalo de referencia por edad y sexo", "referenceHba1c": "HbA1c de {low} a {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} a {high}", "openBound": "abierto", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Bienestar" }, "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." + "readFailed": "Este pilar no se pudo leer en el último cálculo." }, "presets": { "label": "Punto de partida", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} frente a tu valor de referencia", "deltaFlat": "En línea con tu valor de referencia", "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", - "justIn": "Recién llegado", - "justInAt": "Recién llegado · {time}", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", "ringLink": "Abrir detalles de {metric}" diff --git a/messages/fr.json b/messages/fr.json index 8872899d3..f850b79f5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2295,7 +2295,6 @@ "sleep": "Sommeil", "adiposity": "Adiposité", "wellbeing": "Bien-être", - "fitness": "Condition physique mesurée", "lipids": "Lipides" }, "observed": "Observé : {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} pas par jour", "observedSleep": "{value} minutes par nuit", "observedAdiposity": "Rapport taille-hauteur {value}", - "observedFitness": "{value} {unit}, mesuré", "referenceActivity": "Le bénéfice se stabilise à {high} pas par jour", "referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé", "referenceAdiposity": "Rapport taille-hauteur inférieur à {high}", - "referenceFitness": "{low} à {high} {unit}, plage de référence selon l'âge et le sexe", "referenceHba1c": "HbA1c de {low} à {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} à {high}", "openBound": "ouvert", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Bien-être" }, "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." + "readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul." }, "presets": { "label": "Point de départ", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} par rapport à ta référence", "deltaFlat": "Conforme à ta référence", "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", - "justIn": "Vient d'arriver", - "justInAt": "Vient d'arriver · {time}", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", "ringLink": "Ouvrir les détails de {metric}" diff --git a/messages/it.json b/messages/it.json index 2d3abdcf0..5362ee653 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2295,7 +2295,6 @@ "sleep": "Sonno", "adiposity": "Adiposità", "wellbeing": "Benessere", - "fitness": "Forma fisica misurata", "lipids": "Lipidi" }, "observed": "Osservato: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} passi al giorno", "observedSleep": "{value} minuti per notte", "observedAdiposity": "Rapporto vita-altezza {value}", - "observedFitness": "{value} {unit}, misurato", "referenceActivity": "Il beneficio si stabilizza a {high} passi al giorno", "referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato", "referenceAdiposity": "Rapporto vita-altezza inferiore a {high}", - "referenceFitness": "Da {low} a {high} {unit}, intervallo di riferimento per età e sesso", "referenceHba1c": "HbA1c da {low} a {high}%", "referenceDetail": "{label} · {source} · {kind} · da {low} a {high}", "openBound": "aperto", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Benessere" }, "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." + "readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo." }, "presets": { "label": "Punto di partenza", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento", "deltaFlat": "In linea con il tuo valore di riferimento", "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", - "justIn": "Appena arrivato", - "justInAt": "Appena arrivato · {time}", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", "ringLink": "Apri i dettagli di {metric}" diff --git a/messages/pl.json b/messages/pl.json index df0a5c772..83d8db5ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2295,7 +2295,6 @@ "sleep": "Sen", "adiposity": "Otłuszczenie", "wellbeing": "Samopoczucie", - "fitness": "Zmierzona wydolność", "lipids": "Lipidy" }, "observed": "Zaobserwowano: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} kroków dziennie", "observedSleep": "{value} minut na noc", "observedAdiposity": "Stosunek obwodu talii do wzrostu {value}", - "observedFitness": "{value} {unit}, zmierzone", "referenceActivity": "Korzyść stabilizuje się przy {high} krokach dziennie", "referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku", "referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}", - "referenceFitness": "Od {low} do {high} {unit}, zakres odniesienia według wieku i płci", "referenceHba1c": "HbA1c od {low} do {high}%", "referenceDetail": "{label} · {source} · {kind} · od {low} do {high}", "openBound": "otwarty", @@ -4914,8 +4911,6 @@ }, "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": { @@ -4923,19 +4918,13 @@ "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." + "wellbeing": "Samopoczucie" }, "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." + "readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu." }, "presets": { "label": "Punkt wyjścia", @@ -9299,8 +9288,6 @@ "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", "deltaFlat": "Zgodnie z Twoją wartością bazową", "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", - "justIn": "Właśnie dotarło", - "justInAt": "Właśnie dotarło · {time}", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}" diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 6ef274132..08fa120b3 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -559,7 +559,6 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { now: scoredAt, profile: { dateOfBirth: user.dateOfBirth, - gender: user.gender, heightCm: user.heightCm ?? null, timezone: userTz, sourcePriorityJson: scoreSourcePriority, diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts index 7cb6f40f6..592d7c67d 100644 --- a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -69,14 +69,13 @@ const SESSION_OK = { user: { id: "user-1", username: "testuser", role: "USER" as const }, }; -const ALL_EIGHT: ScorePillarId[] = [ +const ALL_PILLARS: ScorePillarId[] = [ "BLOOD_PRESSURE", "GLYCAEMIA", "ACTIVITY", "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ]; @@ -154,7 +153,7 @@ describe("GET /api/auth/me/health-score-config", () => { ); expect(res.status).toBe(200); const env = (await res.json()) as { data: ResolvedBody }; - expect(env.data.pillars).toEqual(ALL_EIGHT); + expect(env.data.pillars).toEqual(ALL_PILLARS); expect(env.data.hasSelection).toBe(false); expect(env.data.version).toBe(0); expect(env.data.updatedAt).toBeUndefined(); @@ -172,7 +171,7 @@ describe("GET /api/auth/me/health-score-config", () => { ); const env = (await res.json()) as { data: ResolvedBody }; expect(env.data.pillars).toEqual( - ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), + ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), ); expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); expect(env.data.hasSelection).toBe(true); @@ -185,7 +184,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { it("rejects an unauthenticated request with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(res.status).toBe(401); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -211,7 +210,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { const stored = writtenConfig(); expect(stored.excludedPillars).toEqual( - ALL_EIGHT.filter((id) => !selection.includes(id)), + ALL_PILLARS.filter((id) => !selection.includes(id)), ); expect(stored.version).toBe(1); expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); @@ -230,7 +229,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { }); await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(writtenConfig().version).toBe(8); }); @@ -240,7 +239,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); const env = (await res.json()) as { data: ResolvedBody }; expect(writtenConfig().excludedPillars).toEqual([]); @@ -252,7 +251,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { primeUser(null); await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); }); @@ -334,7 +333,7 @@ describe("PATCH — envelope and transport", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }), + mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }), ); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -344,7 +343,7 @@ describe("PATCH — envelope and transport", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }), + mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }), ); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -373,7 +372,7 @@ describe("PATCH — envelope and transport", () => { resetAt: Date.now() + 30_000, }); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(res.status).toBe(429); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -386,7 +385,7 @@ describe("PATCH — envelope and transport", () => { const res = await (PATCH as (r: Request) => Promise)( mkPatch({ - pillars: ALL_EIGHT, + pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z", }), ); diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index 4f39b0c7f..c423cccc4 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -1,22 +1,17 @@ /** - * The Today hero's "just in" chip and the reaction line's lead replacement. + * The Today hero's arrival handling and the reaction line's lead + * replacement. * - * Two invariants, both of which have a specific failure this project has - * already paid for: + * The hero used to carry a "just in" chip under the score, with the + * arrival's local time on it. It is gone: under a number, "just in" did + * not say what had arrived — the score? a reading? — and the clock face + * changed nothing a person would then do. What is left is the invariant + * that an arrival alone is not a reason to paint anything, and the one + * the reaction line has always had. * - * 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the - * server-dehydrated digest on the SSR pass because it is the LCP element - * (v1.30.9). So anything inside it that differs between the server and the - * browser is a React #418 mismatch. A wall-clock time is exactly that: the - * server's locale and timezone are not the reader's. The chip therefore - * renders its SLOT on both passes and fills the time in only after mount. - * These tests render through `renderToStaticMarkup`, which takes the SERVER - * snapshot of `useSyncExternalStore` — i.e. precisely the pass that must - * carry no formatted time. - * - * 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second - * paragraph. The hero stays one lead line tall, so nothing below it moves - * when the line arrives on a poll. + * NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a + * second paragraph. The hero stays one lead line tall, so nothing below + * it moves when the line arrives on a poll. */ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -53,30 +48,56 @@ function digest(over: Partial = {}): DailyDigest { }; } -/** Any hh:mm clock face — the thing the server must never emit. */ +/** Any hh:mm clock face — the thing the hero must never print here. */ const CLOCK_FACE = /\d{1,2}:\d{2}/; -describe("TodayHero — the just-in chip", () => { - it("renders no chip when nothing landed", () => { - const html = render(); - expect(html).not.toContain('data-slot="today-hero-just-in"'); - }); - - it("renders the chip slot on the server pass, carrying the kind", () => { +describe("TodayHero — an arrival is not a chip", () => { + it("paints no arrival marker when something has landed", () => { const html = render( , ); - // The slot is present on the SSR pass, so the hydration render — which - // takes the same server snapshot — produces identical HTML. - expect(html).toContain('data-slot="today-hero-just-in"'); - expect(html).toContain('data-just-in-kind="sleep_night"'); - expect(html).toContain("Just in"); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + expect(html).not.toContain("Just in"); + expect(html).not.toContain(ARRIVED_AT); }); - it("renders for a bare account when the arrival has no generated line", () => { + it("prints no clock face beside the score", () => { + // The removed chip was the only wall clock on this surface, and a + // wall clock rendered on the server pass is a hydration mismatch as + // well as noise. Neither is possible once nothing prints one. + const html = render( + , + ); + + expect(html).not.toMatch(CLOCK_FACE); + }); + + it("still shows a pending night, which is a different sentence", () => { + // The freshness note says the day is not complete yet, which changes + // how the number should be read. That one stays. + const html = render( + , + ); + + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + }); + + it("renders nothing at all for an account whose only news was the arrival", () => { + // With no chip there is nothing left for such a digest to say, and a + // bordered empty hero above the tile strip would be worse than + // absence. const html = render( { />, ); - expect(html).toContain('data-slot="today-hero"'); - expect(html).toContain('data-slot="today-hero-just-in"'); - }); - - it("emits NO formatted time and no raw ISO instant on the server pass", () => { - const html = render( - , - ); - - const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"')); - // The mismatch itself: a wall clock the browser would render differently. - expect(chip).not.toMatch(CLOCK_FACE); - // And the raw instant must not leak either — it is machine data, not copy. - expect(html).not.toContain(ARRIVED_AT); - }); - - it("keeps the chip muted meta — never an accent, never an opacity modifier", () => { - const html = render( - , - ); - - const row = html.slice( - 0, - html.indexOf('data-slot="today-hero-just-in"') + 400, - ); - expect(row).toContain("text-muted-foreground"); - expect(row).toContain("text-xs"); - // UI-STANDARDS §text: alpha on muted text drops below AA contrast. - expect(html).not.toContain("text-muted-foreground/"); - expect(html).not.toContain("text-primary"); - }); - - it("shows the chip alongside a still-pending night", () => { - // Legitimately co-occurring: a weight landed this morning while last - // night's sleep has not arrived yet. - const html = render( - , - ); - - expect(html).toContain('data-slot="today-hero-sleep-pending"'); - expect(html).toContain('data-slot="today-hero-just-in"'); - }); - - it("localises the chip", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Gerade eingetroffen"); - expect(html).not.toContain("Just in"); + expect(html).not.toContain('data-slot="today-hero"'); }); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 870b27798..97f7a8700 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -37,8 +37,6 @@ import Link from "next/link"; import { Moon } from "lucide-react"; -import { useMounted } from "@/hooks/use-mounted"; -import { formatTime } from "@/lib/format"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -86,32 +84,6 @@ function withoutRepeatedScore( return result.length > 0 ? result : null; } -/** - * The "just in" chip's clock face. - * - * Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as - * an ISO instant precisely so this stays a client concern: the server has no - * business rendering a local wall-clock time it would format under its OWN - * locale and timezone, and any such string would differ from the browser's on - * the hydration render — React #418, a class of bug this project has already - * paid for once. - * - * Goes through the shared `formatTime`, which reads the profile's timezone and - * clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces - * asked the BROWSER instead, which is a different question: someone whose - * profile says Europe/Berlin while their laptop sits in Manila saw a Manila - * time here and a Berlin time on every other surface, for the same instant. - */ -function formatJustInTime(iso: string): string | null { - const at = new Date(iso); - if (Number.isNaN(at.getTime())) return null; - try { - return formatTime(at); - } catch { - return null; - } -} - export function TodayHero({ digest, renderFilteredAllClear = false, @@ -125,7 +97,6 @@ export function TodayHero({ // paints from the server-dehydrated digest on both the SSR and the hydration // render). So the slot renders on both passes and the formatted time fills in // on the first client re-render, keeping those two passes byte-identical. - const mounted = useMounted(); const { keep, letGo } = useCoachCheckinAction(); const dismissItem = usePriorityItemDismiss(); @@ -166,7 +137,6 @@ export function TodayHero({ const signalFallbackLead = hasScore && !lead ? topSignal?.headline?.trim() || null : null; const displayLead = lead ?? signalFallbackLead; - const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null; // A score-only all-clear digest has no narrative content for the leading // column. Keeping the full md ring in that two-column shell left a blank // column beside a 168 px dial and pushed the actual all-clear read below it. @@ -191,8 +161,7 @@ export function TodayHero({ !hasScore && !hasItems && !digest.briefingLead && - !digest.reactionLine && - !digest.justIn + !digest.reactionLine ) { return null; } @@ -267,28 +236,15 @@ export function TodayHero({ {/* In the compact fallback the quiet freshness tier belongs next to the smaller ring. That uses the otherwise-empty leading space and avoids adding a second row below the dial. */} - {compactAllClear && (digest.sleepPending || digest.justIn) ? ( + {compactAllClear && digest.sleepPending ? (
- {digest.sleepPending ? ( -

-

- ) : null} - {digest.justIn ? ( -

- {mounted && justInTime - ? t("daily.today.justInAt", { time: justInTime }) - : t("daily.today.justIn")} -

- ) : null} +

+

) : null} @@ -334,43 +290,18 @@ export function TodayHero({ {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs text-muted-foreground` is the meta floor; never an accent, never an - opacity modifier). Holds the freshness note and the "just in" chip; - they can legitimately co-occur (a weight landed while last night's - sleep is still pending), so they share one wrapping row rather than - competing for the same slot. */} - {!compactAllClear && (digest.sleepPending || digest.justIn) ? ( + opacity modifier). Freshness note (plan §2.4) — provisional day, + last night's sleep not yet folded in. Muted, non-blocking, + refreshes in place when the morning job lands. */} + {!compactAllClear && digest.sleepPending ? (
- {/* Freshness note (plan §2.4) — provisional day, last night's - sleep not yet folded in. Muted, non-blocking, refreshes in - place when the morning job lands. */} - {digest.sleepPending ? ( -

-

- ) : null} - - {/* The "just in" chip — a state change, not a notification. No - badge count, no accent, no animation: the record simply reads - differently on the next refresh, and this names why. It is - present for as long as the arrival is news (the server applies - the window, so the chip expires on a poll rather than on a - client timer) and carries the arrival's local time once the - client has mounted and can format it honestly. */} - {digest.justIn ? ( -

- {mounted && justInTime - ? t("daily.today.justInAt", { time: justInTime }) - : t("daily.today.justIn")} -

- ) : null} +

+

) : null} diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 5ef74d250..29dfcf01b 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -9,6 +9,7 @@ import type { HealthScoreReport, ScorePillarResult, } from "@/lib/analytics/score/types"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HeroStrip } from "../hero-strip"; /** @@ -134,7 +135,6 @@ const ALL_PILLARS = [ "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ] as const; @@ -149,7 +149,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { composition: pillars.map((p) => p.id), configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage, confidence: { score: 100, band: "high" }, @@ -158,7 +158,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { pillars, delta: 2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: { status: "insufficient", coverage: { diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 1301de90d..41283bd96 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -7,6 +7,7 @@ import type { HealthScoreReport, ScorePillarResult, } from "@/lib/analytics/score/types"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HealthScoreCard, HealthScoreCardSkeleton, @@ -211,7 +212,7 @@ function report(overrides: Partial = {}): HealthScoreReport { pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR], delta: null, deltaReason: "no_current_score", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: { status: "insufficient", coverage: { @@ -241,7 +242,7 @@ function scoredReport( composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: { requiredInputs: 3, @@ -371,7 +372,7 @@ describe(" pillars", () => { composition: ["BLOOD_PRESSURE", "GLYCAEMIA"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: { requiredInputs: 3, @@ -505,7 +506,7 @@ describe(" pillars", () => { }); describe(" one pillar, one home", () => { - /** Four scored, one failed read, one crisis, two plain absences. */ + /** Four scored, one failed read, one crisis, one plain absence. */ function mixedReport() { return scoredReport({ pillars: [ @@ -513,10 +514,9 @@ describe(" one pillar, one home", () => { scoredAt("GLYCAEMIA", 74), scoredAt("ACTIVITY", 61), scoredAt("SLEEP", 45), - gatedPillar("FITNESS", "read_failed"), + gatedPillar("LIPIDS", "read_failed"), gatedPillar("WELLBEING", "crisis_signposting"), gatedPillar("ADIPOSITY", "missing_height"), - gatedPillar("LIPIDS", "not_tracked"), ], }); } @@ -538,14 +538,14 @@ describe(" one pillar, one home", () => { "ACTIVITY", "SLEEP", ]); - expect(failedIds).toEqual(["FITNESS"]); + expect(failedIds).toEqual(["LIPIDS"]); expect(crisisIds).toEqual(["WELLBEING"]); - expect(counted).toBe(2); + expect(counted).toBe(1); - // The four homes partition the eight pillars: disjoint, and complete. + // The four homes partition the seven pillars: disjoint, and complete. const named = [...rowIds, ...failedIds, ...crisisIds]; expect(new Set(named).size).toBe(named.length); - expect(named.length + counted).toBe(8); + expect(named.length + counted).toBe(7); }); it("never renders one pillar id twice anywhere in the panel", () => { @@ -556,14 +556,7 @@ describe(" one pillar, one home", () => { const ids = pillarAttrs(html); expect(new Set(ids).size).toBe(ids.length); expect(ids.sort()).toEqual( - [ - "ACTIVITY", - "ADIPOSITY", - "BLOOD_PRESSURE", - "GLYCAEMIA", - "LIPIDS", - "SLEEP", - ].sort(), + ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE", "GLYCAEMIA", "SLEEP"].sort(), ); }); @@ -581,7 +574,6 @@ describe(" one pillar, one home", () => { "Sleep", "Adiposity", "Wellbeing", - "Measured fitness", "Lipids", ]) { const hits = listed.split(label).length - 1; @@ -609,7 +601,7 @@ describe(" composite states", () => { expect(html).not.toContain('data-slot="health-score-card-progress"'); expect(html).toContain("—"); expect(html).not.toContain("/ 100"); - expect(html).toContain("Method version 2"); + expect(html).toContain(`Method version ${SCORE_VERSION}`); }); it("explains a missing comparison where the trend would be", () => { @@ -674,7 +666,9 @@ describe(" footer", () => { const region = anatomyRegion(html); expect(region).toContain('data-slot="health-score-method"'); expect(region).toContain("Overall band set by none"); - expect(region).toContain("Method version 2. Weekly comparison floor"); + expect(region).toContain( + `Method version ${SCORE_VERSION}. Weekly comparison floor`, + ); expect(region).toContain('data-slot="provenance-explainer-method"'); expect(region).toContain("equal-weighted average"); }); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index dc4c21e75..7f59ad7b3 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -8,6 +8,7 @@ import type { ScorePillarId, ScorePillarResult, } from "@/lib/analytics/score/types"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; // The "generated" freshness line reads the profile timezone via `useAuth`; stub // it so the SSR render does not reach for a QueryClient the test omits. @@ -116,7 +117,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport { composition: ["BLOOD_PRESSURE"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: { requiredInputs: 3, @@ -130,7 +131,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport { pillars, delta: 2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: { status: "insufficient", coverage: { diff --git a/src/components/insights/health-score-pillar-detail.ts b/src/components/insights/health-score-pillar-detail.ts index f947bd106..fc43535b2 100644 --- a/src/components/insights/health-score-pillar-detail.ts +++ b/src/components/insights/health-score-pillar-detail.ts @@ -82,12 +82,6 @@ export function pillarObservedText( value: observed.value, }); } - if (pillar.id === "FITNESS") { - return t("insights.healthScore.observedFitness", { - value: observed.value, - unit: observed.unit, - }); - } return observed.label; } @@ -129,18 +123,12 @@ function pillarReferenceText( ? t("insights.healthScore.referenceAdiposity", { high: reference.high ?? "", }) - : pillar.id === "FITNESS" - ? t("insights.healthScore.referenceFitness", { + : pillar.id === "GLYCAEMIA" + ? t("insights.healthScore.referenceHba1c", { low: reference.low ?? "", high: reference.high ?? "", - unit: value.observed.unit, }) - : pillar.id === "GLYCAEMIA" - ? t("insights.healthScore.referenceHba1c", { - low: reference.low ?? "", - high: reference.high ?? "", - }) - : reference.label; + : reference.label; return formatReference( reference, label, diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx index 85101c7ee..1f73430cc 100644 --- a/src/components/settings/__tests__/score-pillar-row.test.tsx +++ b/src/components/settings/__tests__/score-pillar-row.test.tsx @@ -25,27 +25,28 @@ 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"); - }); - +describe("the row", () => { it("offers exactly one switch, and it is the score one", () => { const html = render(base); + expect(html).toContain('data-slot="score-pillar-switch"'); expect(html.match(/role="switch"/g) ?? []).toHaveLength(1); expect(html).toContain("Count Sleep toward the score"); }); + + it("says nothing about recording or showing", () => { + // Those two belong to the modules screen. Restating them under every + // pillar put the same two words on every row of a page about a third + // question, and the sentence above the rows already says so once. + const html = render(base); + + expect(html).not.toContain('data-slot="score-pillar-axes"'); + expect(html).not.toContain("Being recorded"); + expect(html).not.toContain("Shown in the app"); + }); }); describe("row attributes", () => { @@ -106,38 +107,6 @@ describe("a failed read", () => { }); }); -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 diff --git a/src/components/settings/__tests__/score-section-saved-selection.test.tsx b/src/components/settings/__tests__/score-section-saved-selection.test.tsx new file mode 100644 index 000000000..2e92203aa --- /dev/null +++ b/src/components/settings/__tests__/score-section-saved-selection.test.tsx @@ -0,0 +1,213 @@ +/** + * What the score settings page does with a selection that is already + * stored, and what it puts on the wire when you press Save. + * + * The v1.35.0 defect this pins the fix for: the page filtered the + * selection on its way to the server, so the answer that came back could + * never match the form that sent it. The switches then re-seeded from the + * server's narrower list — a pillar the person had never touched read as + * switched off — and the draft could never equal the server copy again, + * which left Save lit and the card claiming unsaved changes forever. + * + * Two ends and the pipe between them: + * + * - the rows a stored selection renders (`data-counts` per pillar), + * - the state of the Save button, which is the whole "did this settle" + * question in one attribute, + * - the body the mutation actually hands to `apiPatch`, driven through + * the component's own `mutationFn` rather than restated here. + * + * SSR markup, matching the rest of the settings suite. The draft re-seed + * happens in render, so a static render is enough to see it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; + +const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS]; + +/** The resolved config the page's primary read returns. */ +const configState: { pillars: ScorePillarId[] } = { pillars: [...ALL] }; + +/** Every `mutationFn` the component registered, newest last. */ +const mutationFns: Array<(input: never) => unknown> = []; + +const apiPatch = vi.fn(async () => ({ pillars: configState.pillars })); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + // The factory's key, not a guess: `queryKeys.healthScoreConfig()`. + if ( + Array.isArray(queryKey) && + queryKey[0] === "settings" && + queryKey[1] === "health-score-config" + ) { + return { + data: { + pillars: configState.pillars, + excludedPillars: ALL.filter( + (id) => !configState.pillars.includes(id), + ), + hasSelection: true, + version: 1, + changedAt: null, + updatedAt: "2026-08-01T00:00:00.000Z", + }, + isSuccess: true, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + } + return { + data: undefined, + isSuccess: false, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + }, + useMutation: (options: { mutationFn?: (input: never) => unknown }) => { + if (options.mutationFn) mutationFns.push(options.mutationFn); + return { mutate: vi.fn(), isPending: false }; + }, + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("sonner", () => ({ + toast: { message: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPatch: (...args: unknown[]) => + (apiPatch as unknown as (...a: unknown[]) => unknown)(...args), + ApiError: class ApiError extends Error {}, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false, user: {} }), +})); + +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => ({ + data: undefined, + isError: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ScoreSection } from "../score-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +/** `data-counts` per pillar, read off the rendered rows. */ +function countsByPillar(html: string): Record { + const out: Record = {}; + for (const match of html.matchAll( + /data-pillar="([A-Z_]+)"[^>]*data-counts="(true|false)"/g, + )) { + out[match[1]] = match[2]; + } + return out; +} + +function saveIsDisabled(html: string): boolean { + const at = html.indexOf('data-slot="score-config-save"'); + expect(at, "save button is rendered").toBeGreaterThan(-1); + const open = html.lastIndexOf("", at)).includes("disabled="); +} + +beforeEach(() => { + configState.pillars = [...ALL]; + mutationFns.length = 0; + apiPatch.mockClear(); +}); + +describe("a selection that is already stored", () => { + it("shows every pillar the person kept switched on, and only those", () => { + configState.pillars = ALL.filter( + (id) => id !== "SLEEP" && id !== "WELLBEING", + ); + + const counts = countsByPillar(render()); + + expect(Object.keys(counts).sort()).toEqual([...ALL].sort()); + for (const id of ALL) { + expect(counts[id], `${id} switch`).toBe( + configState.pillars.includes(id) ? "true" : "false", + ); + } + }); + + it("has nothing to save the moment it loads", () => { + configState.pillars = ALL.filter((id) => id !== "SLEEP"); + + expect(saveIsDisabled(render())).toBe(true); + }); + + it("settles on the every-pillar starting point too", () => { + // "All pillars" has to be a selection the page can hold: if the save + // silently narrowed it, the form would come back different from the + // list it sent and never read as saved again. + configState.pillars = [...ALL]; + const html = render(); + + expect(Object.values(countsByPillar(html))).toEqual(ALL.map(() => "true")); + expect(saveIsDisabled(html)).toBe(true); + }); +}); + +describe("what the save puts on the wire", () => { + it("sends the selection unchanged, pillar for pillar", async () => { + render(); + const save = mutationFns[0]; + expect(save, "the save mutation registered a mutationFn").toBeTypeOf( + "function", + ); + + await (save as (input: ScorePillarId[]) => unknown)([...ALL]); + + expect(apiPatch).toHaveBeenCalledTimes(1); + const [path, body] = apiPatch.mock.calls[0] as unknown as [ + string, + { pillars: ScorePillarId[] }, + ]; + expect(path).toBe("/api/auth/me/health-score-config"); + expect(body.pillars).toEqual(ALL); + }); + + it("puts the selection in registry order whatever order it arrives in", async () => { + render(); + const save = mutationFns[0] as (input: ScorePillarId[]) => unknown; + + await save(["LIPIDS", "ACTIVITY", "BLOOD_PRESSURE"]); + + const [, body] = apiPatch.mock.calls[0] as unknown as [ + string, + { pillars: ScorePillarId[] }, + ]; + expect(body.pillars).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "LIPIDS"]); + }); +}); diff --git a/src/components/settings/score-pillar-row.tsx b/src/components/settings/score-pillar-row.tsx index 3dd1639b2..99853a15c 100644 --- a/src/components/settings/score-pillar-row.tsx +++ b/src/components/settings/score-pillar-row.tsx @@ -3,15 +3,11 @@ /** * 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. + * The row carries the pillar's name, one line about where it currently + * stands with the server, and the switch that decides whether it counts. + * Nothing else: recording and showing are the modules screen's business, + * and repeating them on every row was noise on a page about a third + * question. * * Presentation only. The parent owns the draft, the mutation and the * cache, so this stays a leaf a test can drive directly. @@ -35,7 +31,6 @@ const ELIGIBILITY_LINE_KEYS: Partial< 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 { @@ -65,32 +60,17 @@ export function ScorePillarRow({ data-domain={row.domain} data-counts={row.counts ? "true" : "false"} data-eligibility={row.eligibility} - data-selectable={row.selectable ? "true" : "false"} - className="flex items-start justify-between gap-4 py-3" + className="flex min-h-11 items-center justify-between gap-4" > -
+
- {/* 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)}

@@ -104,30 +84,16 @@ export function ScorePillarRow({ className="text-muted-foreground size-3.5 animate-spin motion-reduce:animate-none" /> ) : null} - {row.selectable ? ( - onToggle(next)} - aria-label={t("settings.sections.score.axis.countsFor", { - pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]), - })} - /> - ) : ( - // Not a disabled switch pretending to be a choice: a pillar this - // build can never score has no state to show, so it says so. - // `aria-disabled` + the opacity container is the standard - // disabled look (UI-STANDARDS §3). - - {t("settings.sections.score.state.unavailable")} - - )} + onToggle(next)} + aria-label={t("settings.sections.score.axis.countsFor", { + pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]), + })} + />
); diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 99334a8f5..7eb58c8db 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -14,13 +14,18 @@ * - **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. + * - **The settings card anatomy, unmodified.** Header through + * `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on + * the title's reading edge, save bottom-right in its own row. The + * v1.35.0 cut hung the save in the header's status slot and let the + * body start at the card edge, which read as a different kind of card + * from the ones above and below it. + * - **Grouped by domain, and the grouping is spacing.** Blood pressure, + * glycaemia and lipids sit under one heading; the gap between groups + * does the separating. Rules between every row made a short list look + * like a table of unrelated things. + * - **Only the score toggle is writable.** Recording and showing belong + * to the modules screen and are not restated per row. * - **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 @@ -66,16 +71,13 @@ import { 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 { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels"; import { buildScoreConfigRows, - selectionToSave, type ScorePillarVerdict, } from "@/lib/score-config/rows"; import { + orderedUniquePillars, SCORE_PILLAR_IDS, type ScorePillarId, } from "@/lib/analytics/score/types"; @@ -191,7 +193,13 @@ export function ScoreSection() { apiPatch( "/api/auth/me/health-score-config", withBaseToken( - { pillars: selectionToSave(selection) }, + // Registry order, no duplicates. What the person sees is what + // the server stores: every listed pillar is a real choice, so + // nothing is filtered out of the selection on the way to the + // write. A selection that came back different from the one that + // was sent is what left the page permanently claiming unsaved + // changes in v1.35.0. + { pillars: orderedUniquePillars(selection) }, readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()), ), ), @@ -295,157 +303,167 @@ export function ScoreSection() { as="section" data-slot="score-config-card" aria-labelledby="score-config-title" - className="space-y-4" > saveMutation.mutate(draft)} - disabled={busy || !dirty || configQuery.isLoading} - data-slot="score-config-save" - className="min-h-11 sm:min-h-9" - > - {busy ? ( -