diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts new file mode 100644 index 000000000..9a66f45f2 --- /dev/null +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -0,0 +1,268 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * An add / edit dialog must not pan sideways, and nothing you have to operate + * may sit outside its visible box. + * + * A report named four surfaces — measurements add, mood add, the medication + * intake editor, the cycle log sheet — and four surfaces sharing one symptom + * is one defect in something shared, not four. Two shared mechanisms were + * behind it, and this spec pins the outcome of both rather than either + * implementation: + * + * 1. `` / `` wrap a text ``. A flex item's + * `min-width: auto` resolves to a content-based minimum, and for a text + * input that minimum is its intrinsic `size` (20 characters, ~180 px). + * The date + time row therefore demanded ~470 px inside a 400 px dialog + * body and put the clock button ~58 px past the right edge — the picker + * was not merely ugly, it was unreachable without scrolling sideways. + * + * 2. The body scrolls (`overflow-y-auto`), and CSS promotes the OTHER axis + * from `visible` to `auto`. So every intentional outward bleed in the + * tree — ``'s trigger row, ``'s `inset-[-13px]` + * hit-target pseudo — turned the whole form into a horizontally + * scrollable surface. That is why the desktop dialog showed two + * scrollbars while the phone sheet, whose body has padding, looked fine. + * + * The two assertions map onto the two harms and neither is a restatement of a + * class name: + * + * - `expectNoSidewaysScroll` — the surface is not a horizontal scroll + * container. Fails against mechanism 2 (the axis was promoted to `auto`). + * - `expectControlsInsideBox` — every focusable control is within the + * surface's visible content box. Fails against mechanism 1 (the clock + * button was outside it), and keeps failing if a future change clips a + * control out of sight instead of fitting it. + * + * Both widths run: the defect was desktop-only for the date/time row, and the + * phone sheet is the branch where a regression would be least visible. + * + * NOT covered here: the medication intake editor and the cycle log sheet, the + * other two reported surfaces. Both need account state the shared fixture does + * not carry (a medication with a logged dose; a profile with cycle tracking + * on), and a spec that silently measures an empty page proves nothing. They + * share the primitives asserted below — `` + `` + * for the editor, `` + `` for the log sheet — + * so a regression in either mechanism surfaces here first. + */ + +const SURFACE = + '[data-slot="responsive-sheet-content"], [data-slot="dialog-content"]'; +const BODY = '[data-slot="responsive-sheet-body"]'; + +interface DialogCase { + name: string; + path: string; + /** Accessible name of the control that opens the dialog. */ + trigger: RegExp; + /** Reach the state the report describes, when the form does not open in it. */ + prepare?: (page: import("@playwright/test").Page) => Promise; + /** Something inside the dialog that proves it rendered its form, not a shell. */ + ready: string; +} + +const DIALOGS: DialogCase[] = [ + { + name: "measurements add", + path: "/measurements", + trigger: /^add$/i, + ready: '[data-slot="date-time-field"]', + }, + { + name: "mood add", + path: "/mood", + trigger: /^add$/i, + // The mood sheet opens on the five-face quick check; the timestamp row + // (and everything else) appears once a face is picked. Measuring the + // quick check alone would measure a sheet that has almost nothing in it. + prepare: async (page) => { + await page.locator('[data-slot="mood-face"]').first().click(); + await page + .locator('[data-slot="mood-annotate-panel"]') + .first() + .waitFor({ state: "visible" }); + }, + ready: '[data-slot="date-time-field"]', + }, + { + name: "labs add", + path: "/labs", + trigger: /^add$/i, + ready: '[data-slot="date-time-field"]', + }, +]; + +const VIEWPORTS = [ + { label: "desktop 1280", width: 1280, height: 800 }, + { label: "phone 390", width: 390, height: 844 }, +]; + +/** + * The surface and its scrolling body must not be horizontal scroll + * containers. Read from the COMPUTED style, because the promotion that caused + * the defect happens in the cascade and is invisible in the class list. + */ +async function expectNoSidewaysScroll( + page: import("@playwright/test").Page, + label: string, +): Promise { + const boxes = await page.evaluate( + ([surfaceSel, bodySel]) => { + const nodes = [ + ...document.querySelectorAll(surfaceSel), + ...document.querySelectorAll(bodySel), + ]; + return nodes.map((el) => ({ + slot: el.dataset.slot ?? el.tagName.toLowerCase(), + overflowX: getComputedStyle(el).overflowX, + })); + }, + [SURFACE, BODY] as const, + ); + + expect( + boxes.length, + `${label}: no dialog surface found to measure`, + ).toBeGreaterThan(0); + for (const box of boxes) { + expect( + ["auto", "scroll"].includes(box.overflowX), + `${label}: ${box.slot} computes overflow-x:${box.overflowX} — the surface can be panned sideways`, + ).toBe(false); + } +} + +/** + * No focusable control may lie outside the surface's visible content box. + * This is the reachability claim: a picker pushed past the edge (or clipped + * out of sight by a later "fix") fails here regardless of how the layout got + * there. + */ +async function expectControlsInsideBox( + page: import("@playwright/test").Page, + label: string, +): Promise { + const result = await page.evaluate((surfaceSel) => { + const surface = document.querySelector(surfaceSel); + if (!surface) return null; + const box = surface.getBoundingClientRect(); + // The visible content box: `clientWidth` excludes a vertical scrollbar, + // so a control hidden behind one counts as outside. + const visibleRight = box.left + surface.clientWidth; + const controls = [ + ...surface.querySelectorAll( + "button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])", + ), + ]; + const outside = controls + .map((el) => { + const r = el.getBoundingClientRect(); + return { el, r }; + }) + .filter(({ r }) => r.width > 0 && r.height > 0) + .filter(({ r }) => r.right > visibleRight + 1 || r.left < box.left - 1) + .map(({ el, r }) => ({ + tag: el.tagName.toLowerCase(), + slot: el.dataset.slot ?? null, + label: + el.getAttribute("aria-label") ?? + el.textContent?.trim().slice(0, 30) ?? + "", + left: Math.round(r.left), + right: Math.round(r.right), + })); + return { + controlCount: controls.length, + surfaceLeft: Math.round(box.left), + visibleRight: Math.round(visibleRight), + outside, + }; + }, SURFACE); + + expect(result, `${label}: dialog surface not found`).not.toBeNull(); + // Guard the guard: a dialog with no controls would pass vacuously. + expect( + result!.controlCount, + `${label}: the dialog rendered no focusable controls, so this assertion proves nothing`, + ).toBeGreaterThan(2); + expect( + result!.outside, + `${label}: control(s) outside the dialog's visible box [${result!.surfaceLeft}..${result!.visibleRight}]`, + ).toEqual([]); +} + +test.describe("add/edit dialogs do not scroll sideways", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const vp of VIEWPORTS) { + for (const dialog of DIALOGS) { + test(`${dialog.name} fits its surface at ${vp.label}`, async ({ + page, + }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await page.goto(dialog.path, { waitUntil: "domcontentloaded" }); + + await page + .getByRole("button", { name: dialog.trigger }) + .first() + .click(); + + const surface = page.locator(SURFACE).first(); + await expect(surface).toBeVisible(); + await dialog.prepare?.(page); + // Gate on the form's own content, not the shell: the surface mounts + // before the fields do, and measuring the empty frame would pass + // against exactly the layout this spec exists to catch. + await expect(surface.locator(dialog.ready).first()).toBeVisible(); + + const label = `${dialog.name} @${vp.label}`; + await expectNoSidewaysScroll(page, label); + await expectControlsInsideBox(page, label); + }); + } + } + + /** + * The reachability assertion has to be able to FAIL, or the six tests above + * are decoration. Inject a control that deliberately sits past the right + * edge and assert the helper reports it — the same shape the time-field + * button had before the fix. + */ + test("the reachability check detects a control pushed past the edge", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: /^add$/i }).first().click(); + + const surface = page.locator(SURFACE).first(); + await expect(surface).toBeVisible(); + await expect( + surface.locator('[data-slot="date-time-field"]').first(), + ).toBeVisible(); + + await page.evaluate((surfaceSel) => { + const el = document.querySelector(surfaceSel)!; + const probe = document.createElement("button"); + probe.id = "reach-canary"; + probe.textContent = "canary"; + probe.style.position = "absolute"; + probe.style.left = `${el.clientWidth + 40}px`; + probe.style.top = "0"; + probe.style.width = "30px"; + probe.style.height = "30px"; + el.appendChild(probe); + }, SURFACE); + + await expect( + page.locator(`${SURFACE} #reach-canary`).first(), + "the canary is not inside the dialog — the fixture failed, not the helper", + ).toBeAttached(); + + await expect(expectControlsInsideBox(page, "canary probe")).rejects.toThrow( + /outside the dialog's visible box/, + ); + }); +}); diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..722d99e3b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5744,7 +5744,7 @@ "notificationStatus": { "title": "Zustellstatus", "description": "Pro Kanal aktueller Status; \"Automatisch deaktiviert\" stoppt nach dauerhaften Fehlern.", - "emptyDescription": "Noch keine Kanäle konfiguriert. Lege unten einen Kanal an, um die Zustellung zu überwachen.", + "emptyDescription": "Noch keine Kanäle konfiguriert. Richte unter Zustellkanäle einen Kanal ein, um die Zustellung zu verfolgen.", "stateActive": "Aktiv", "stateAutoDisabled": "Automatisch deaktiviert", "stateSendingPaused": "Versand pausiert", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.", "measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.", "focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.", + "reminderSurfacedTitle": "Deine Erinnerung", + "reminderSurfacedLeadOne": "Du wolltest heute daran erinnert werden:", + "reminderSurfacedLeadMany": "Du wolltest heute an {count} Dinge erinnert werden:", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Seelisches Wohlbefinden", - "pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.", - "moodLink": "Deinen Stimmungsverlauf ansehen", + "pageDescription": "Vier validierte, freiwillige Selbsttests — aussagekräftig ist der Verlauf, nicht ein einzelner Wert.", "attributionLabel": "Quelle", "attributionShow": "Quelle und Lizenz für {instrument}", "choosePrompt": "Check-in auswählen", @@ -8842,6 +8844,7 @@ "saveError": "Lebensstil-Kontext konnte nicht gespeichert werden", "loadError": "Lebensstil-Kontext konnte nicht geladen werden", "remove": "{kind} entfernen", + "removeShort": "Entfernen", "removeConfirmTitle": "{kind} entfernen?", "removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.", "removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..7afbc0765 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5744,7 +5744,7 @@ "notificationStatus": { "title": "Delivery status", "description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.", - "emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.", + "emptyDescription": "No channels configured yet. Set one up under Delivery channels to start tracking delivery.", "stateActive": "Active", "stateAutoDisabled": "Auto-disabled", "stateSendingPaused": "Sending paused", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.", "measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.", "focusNote": "It also ties into what you said you wanted to keep an eye on.", + "reminderSurfacedTitle": "The reminder you asked for", + "reminderSurfacedLeadOne": "You asked me to bring this back to you today:", + "reminderSurfacedLeadMany": "You asked me to bring {count} things back to you today:", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Mental wellbeing", - "pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.", - "moodLink": "View your mood history", + "pageDescription": "Four validated, voluntary self-checks — the trend over time says more than any single score.", "attributionLabel": "Attribution", "attributionShow": "Source and licence for {instrument}", "choosePrompt": "Choose a check-in", @@ -8842,6 +8844,7 @@ "saveError": "Could not save lifestyle context", "loadError": "Could not load lifestyle context", "remove": "Remove {kind}", + "removeShort": "Remove", "removeConfirmTitle": "Remove {kind}?", "removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.", "removedToast": "{kind} removed from current lifestyle context", diff --git a/messages/es.json b/messages/es.json index a800a3727..8f6a2f6b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5742,9 +5742,9 @@ } }, "notificationStatus": { - "title": "Channel reliability", - "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "Aún no hay canales configurados. Añade un canal abajo para empezar a controlar su estado de entrega.", + "title": "Fiabilidad de los canales", + "description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.", + "emptyDescription": "Aún no hay canales configurados. Configura uno en Canales de entrega para seguir la entrega.", "stateActive": "Activo", "stateAutoDisabled": "Desactivado automáticamente", "stateSendingPaused": "Envío en pausa", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.", "measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.", "focusNote": "También tiene que ver con eso que querías vigilar.", + "reminderSurfacedTitle": "Tu recordatorio", + "reminderSurfacedLeadOne": "Me pediste que hoy te recordara esto:", + "reminderSurfacedLeadMany": "Me pediste que hoy te recordara {count} cosas:", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Bienestar mental", - "pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.", - "moodLink": "Ver tu historial de estado de ánimo", + "pageDescription": "Cuatro autoevaluaciones validadas y voluntarias: cuenta la evolución, no un valor aislado.", "attributionLabel": "Fuente", "attributionShow": "Fuente y licencia de {instrument}", "choosePrompt": "Elige una revisión", @@ -8842,6 +8844,7 @@ "saveError": "No se pudo guardar el contexto del estilo de vida", "loadError": "No se pudo cargar el contexto del estilo de vida", "remove": "Eliminar {kind}", + "removeShort": "Quitar", "removeConfirmTitle": "¿Eliminar {kind}?", "removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.", "removedToast": "{kind} eliminado del contexto actual del estilo de vida", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..f26b406aa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5742,9 +5742,9 @@ } }, "notificationStatus": { - "title": "Channel reliability", - "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez un canal ci-dessous pour commencer à suivre l’état de sa livraison.", + "title": "Fiabilité des canaux", + "description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.", + "emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez-en un dans Canaux de diffusion pour suivre la distribution.", "stateActive": "Actif", "stateAutoDisabled": "Désactivé automatiquement", "stateSendingPaused": "Envoi en pause", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.", "measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.", "focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.", + "reminderSurfacedTitle": "Ton rappel", + "reminderSurfacedLeadOne": "Tu m'as demandé de te rappeler ceci aujourd'hui :", + "reminderSurfacedLeadMany": "Tu m'as demandé de te rappeler {count} choses aujourd'hui :", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Bien-être mental", - "pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.", - "moodLink": "Voir ton historique d'humeur", + "pageDescription": "Quatre auto-évaluations validées et facultatives : l’évolution compte plus qu’un score isolé.", "attributionLabel": "Source", "attributionShow": "Source et licence pour {instrument}", "choosePrompt": "Choisis un bilan", @@ -8842,6 +8844,7 @@ "saveError": "Impossible d'enregistrer le contexte du mode de vie", "loadError": "Impossible de charger le contexte du mode de vie", "remove": "Supprimer : {kind}", + "removeShort": "Supprimer", "removeConfirmTitle": "Supprimer : {kind} ?", "removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.", "removedToast": "{kind} supprimé du contexte actuel du mode de vie", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..7175278a8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5742,9 +5742,9 @@ } }, "notificationStatus": { - "title": "Channel reliability", - "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "Nessun canale ancora configurato. Aggiungi un canale qui sotto per iniziare a monitorarne la consegna.", + "title": "Affidabilità dei canali", + "description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.", + "emptyDescription": "Nessun canale ancora configurato. Aggiungine uno in Canali di recapito per monitorarne la consegna.", "stateActive": "Attivo", "stateAutoDisabled": "Disattivato automaticamente", "stateSendingPaused": "Invio in pausa", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.", "measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.", "focusNote": "Si lega anche a ciò che volevi tenere d'occhio.", + "reminderSurfacedTitle": "Il tuo promemoria", + "reminderSurfacedLeadOne": "Mi avevi chiesto di ricordarti questo oggi:", + "reminderSurfacedLeadMany": "Mi avevi chiesto di ricordarti {count} cose oggi:", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Benessere mentale", - "pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.", - "moodLink": "Vedi il tuo andamento dell'umore", + "pageDescription": "Quattro autovalutazioni validate e facoltative: conta l’andamento, non il singolo punteggio.", "attributionLabel": "Fonte", "attributionShow": "Fonte e licenza di {instrument}", "choosePrompt": "Scegli un check-in", @@ -8842,6 +8844,7 @@ "saveError": "Impossibile salvare il contesto dello stile di vita", "loadError": "Impossibile caricare il contesto dello stile di vita", "remove": "Rimuovi {kind}", + "removeShort": "Rimuovi", "removeConfirmTitle": "Rimuovere {kind}?", "removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.", "removedToast": "{kind} rimosso dal contesto attuale dello stile di vita", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..972357d82 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5742,9 +5742,9 @@ } }, "notificationStatus": { - "title": "Channel reliability", - "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał poniżej, aby zacząć śledzić jego dostarczalność.", + "title": "Niezawodność kanałów", + "description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.", + "emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał w sekcji Kanały dostarczania, aby śledzić dostarczalność.", "stateActive": "Aktywne", "stateAutoDisabled": "Automatycznie wyłączone", "stateSendingPaused": "Wysyłanie wstrzymane", @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.", "measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.", "focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.", + "reminderSurfacedTitle": "Twoje przypomnienie", + "reminderSurfacedLeadOne": "Prosiłeś, żeby przypomnieć ci o tym dzisiaj:", + "reminderSurfacedLeadMany": "Prosiłeś, żeby przypomnieć ci dzisiaj o {count} sprawach:", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", @@ -8586,8 +8589,7 @@ }, "mentalHealth": { "pageTitle": "Dobrostan psychiczny", - "pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.", - "moodLink": "Zobacz historię swojego nastroju", + "pageDescription": "Cztery zwalidowane, dobrowolne testy — liczy się przebieg w czasie, nie pojedynczy wynik.", "attributionLabel": "Źródło", "attributionShow": "Źródło i licencja: {instrument}", "choosePrompt": "Wybierz sprawdzenie", @@ -8842,6 +8844,7 @@ "saveError": "Nie udało się zapisać kontekstu stylu życia", "loadError": "Nie udało się wczytać kontekstu stylu życia", "remove": "Usuń: {kind}", + "removeShort": "Usuń", "removeConfirmTitle": "Usunąć: {kind}?", "removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.", "removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia", diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index bfbf07b1a..452af57da 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -6,8 +6,10 @@ * Groups the user's live numeric lab results by panel date, pairs the two * most-recent panels, and reports the per-analyte delta + reference-band * standing (`summariseLabChanges`). Absent when there are fewer than two - * panels or no analyte is shared. Neutral framing only — a delta is not a - * diagnosis. + * panels, no analyte is shared, or the newest panel is older than the + * recency window — the card's "since your last panel" wording is a claim + * about recent news, and it stops being made rather than being made about a + * years-old draw. Neutral framing only — a delta is not a diagnosis. * * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` * module gate, the shared analytics-read budget. `userId` is narrowed from the @@ -62,7 +64,13 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "insights.labs-changes.read" }, - meta: { present: summary.present, changes: summary.changes.length }, + meta: { + present: summary.present, + changes: summary.changes.length, + // Distinguishes "no comparable panels" from "the comparison aged out", + // so an absent card is diagnosable without reaching for the database. + lab_rows: rows.length, + }, }); return apiSuccess({ ...summary, generatedAt: new Date().toISOString() }); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 1ee7ef2c9..caa593af2 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -267,11 +267,15 @@ describe("", () => { expect(controls?.[0]).not.toMatch(/\bjustify-between\b/); expect(controls?.[0]).toMatch(/\bshrink-0\b/); - // And the row itself is a row at every width, bottom-aligned so the send - // button stays beside the last line as the textarea grows. + // And the row itself is a row at every width. The textarea is centred in + // it — bottom-aligning a one-line textarea against a 44 px tap target left + // the placeholder sitting visibly low in the pill — while the controls + // keep their own bottom alignment, so the send button stays beside the + // LAST line as the textarea grows rather than drifting to the middle. const shell = html.match(/]*class="[^"]*\bflex-row\b[^"]*"[^>]*>/); - expect(shell?.[0]).toMatch(/\bitems-end\b/); + expect(shell?.[0]).toMatch(/\bitems-center\b/); expect(shell?.[0]).not.toMatch(/\bflex-col\b/); + expect(controls?.[0]).toMatch(/\bself-end\b/); }); it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index a17d49b7e..44f6783b8 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -427,9 +427,15 @@ export function CoachInput({ // hold: focus follows DOM order, and the DOM order (textarea, // actions, Send) is identical in both layouts. Nothing jumps. // - // `items-end` keeps the controls beside the LAST line as the textarea - // grows, so the send button stays where the thumb left it. - "flex flex-row items-end gap-1.5 p-1.5", + // `items-center` with the controls pinned `self-end` below. The row + // used to be `items-end` throughout, which bottom-aligned an empty + // one-line textarea against a 44 px touch target: the placeholder sat + // roughly 6 px low in the pill, reading as if the text had slipped. + // Centring the textarea fixes the empty and single-line cases, and + // `self-end` on the controls preserves the reason `items-end` was + // there — as the textarea grows, the send button stays beside the + // LAST line rather than drifting to the middle. + "flex flex-row items-center gap-1.5 p-1.5", )} >