diff --git a/CHANGELOG.md b/CHANGELOG.md index 69dfb6b15..f94ce3040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +### Security + +- The PDF renderer behind the document vault moves to a release that closes a + script-execution flaw. A crafted PDF could get JavaScript to run inside the + renderer, and the vault feeds it every PDF that is uploaded. Two things + limited what that reached, and neither is a substitute for the fix: the + renderer runs server-side only, because a PDF is displayed through the + browser's own viewer rather than this library, and it already ran with font + evaluation turned off. A second copy of the same library, pulled in by a + development dependency, is pinned past the flaw as well, since the runtime + image ships every copy it finds rather than only the one that gets used. + +- The container image now runs the version of the PDF renderer the project + pins. The build had been taking whichever copy the filesystem listed first, + and more than one copy reaches the image, so the pinned version was not + reliably the one that ran. It is selected by name now, and a copy that never + arrived fails the build instead of quietly leaving another one in its place. + ## [1.37.0] — 2026-08-07 ### Added diff --git a/Dockerfile b/Dockerfile index 5eeb810ad..24982844a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,13 @@ ENV NEXT_TELEMETRY_DISABLED=1 # starts fresh and therefore does not inherit this setting. RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build +# Record which pdfjs-dist the top-level dependency actually resolves to. pnpm +# links a direct dependency at `node_modules/`, so this reads the exact +# pinned copy rather than guessing. The runner stage hoists that version by +# name; see the note there for why guessing was wrong. +RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ + > /app/.pdfjs-version + # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware @@ -116,13 +123,30 @@ RUN set -e; \ # path breaks in the standalone image), so the server does a runtime bare # `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to # /app/node_modules, so hoist the real pnpm-store copy to the top level too. +# +# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the +# store — `pdf-parse` carries its own, older copy for the text-extraction path — +# and this step used to take whatever `find | head -1` returned first. That is +# readdir order, so it could hoist the OLDER copy and the version named in +# package.json would never run in the image: the renderer resolving one version +# while every local check exercised another. Name the version and fail the build +# when it is absent, instead of degrading to whichever copy comes back first. +COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version RUN set -e; \ - PDFJS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null | head -1)"; \ - if [ -n "$PDFJS_DIR" ]; then \ - ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \ - chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \ - echo "pdfjs-dist hoisted: $PDFJS_DIR"; \ - else echo "WARN: pdfjs-dist not found; PDF rasterization will degrade to local text"; fi + PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \ + PDFJS_DIR="/app/node_modules/.pnpm/pdfjs-dist@${PDFJS_VERSION}/node_modules/pdfjs-dist"; \ + if [ ! -d "$PDFJS_DIR" ]; then \ + echo "ERROR: pinned pdfjs-dist@${PDFJS_VERSION} did not reach the traced image."; \ + echo "Present instead:"; \ + find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null; \ + exit 1; \ + fi; \ + ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \ + chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \ + rm -f /tmp/.pdfjs-version; \ + RESOLVED="$(node -p "require('/app/node_modules/pdfjs-dist/package.json').version")"; \ + [ "$RESOLVED" = "$PDFJS_VERSION" ] || { echo "ERROR: hoisted pdfjs-dist is $RESOLVED, expected $PDFJS_VERSION"; exit 1; }; \ + echo "pdfjs-dist hoisted: $PDFJS_DIR (resolves as $RESOLVED)" # Copy Prisma for migrations (schema, migration SQL, config, engines) COPY --from=builder /app/prisma ./prisma 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/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 6fc2c9aaa..b170c49fe 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -79,6 +79,48 @@ async function openSwitcher(page: Page) { ).toBeVisible(); } +/** + * Run `act` and wait for the document it replaces to be gone. + * + * Entering or leaving a record is a server write followed by + * `window.location.assign`, so the click that starts it returns long before the + * document it produces exists. Everything a test does next races the app's own + * navigation, and both ways of losing that race are silent: + * + * - a `page.goto` issued into the gap is cancelled when the assign commits, + * which Chromium reports as `net::ERR_ABORTED`; + * - or the goto wins, and the page it lands on belongs to the CALLER'S OWN + * record, where a route that is ungranted in somebody else's record is + * simply granted. Measured: `/coach` opened in that gap issued all four of + * the protected reads the test asserts never happen. The assertion was + * reading the wrong record, and it could as easily have passed there. + * + * Neither is visible in the DOM the click leaves behind, which is why waiting + * on the banner cannot settle it — the pre-navigation document already shows + * whatever the next assertion looks for, so the wait returns immediately and + * proves nothing. A marker on `window` can settle it, because a document + * replacement is the one thing that destroys it, and no amount of re-rendering + * inside the old document will. + * + * If the navigation never happens the wait fails, which is the right outcome: + * a switch that did not land is a failure and not something to paper over. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + async function openRecord(page: Page, username: string) { await page.goto("/"); await openSwitcher(page); @@ -93,14 +135,26 @@ async function openRecord(page: Page, username: string) { } const accessLevel = await entry.getAttribute("data-access-level"); const recordKind = await entry.getAttribute("data-record-kind"); - await entry.click(); + await withDocumentReplacement(page, () => entry.click()); + // The switch landed AND the record it landed in is the one on screen. Callers + // may navigate away immediately after this, and until both are true there is + // no record to navigate away from. + await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({ + timeout: 30_000, + }); return { accountId, accessLevel, recordKind }; } async function leaveRecord(page: Page) { - await page.locator('[data-slot="shared-record-banner-exit"]').click(); + // Leaving is the same act pointed the other way, and it has the same gap. + // The banner being gone is satisfied by the document that is on its way out, + // so on its own it is not a wait at all. + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( 0, + { timeout: 30_000 }, ); } @@ -636,13 +690,11 @@ test.describe.serial("managed profile browser journey", () => { // And back out, with the record still on the list. // - // Leaving ends in a full document reload, and a `goto` issued while that - // reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`, - // reproducibly at CI's worker count and not at one. The banner being gone - // is satisfied by the pre-reload DOM, so it cannot be the thing waited on. - // Retrying the navigation is the honest fix: the claim is that the record - // survives the round trip, not that the first navigation attempt wins a - // race with the app's own. + // `leaveRecord` now waits for the app's own reload to land before it + // returns, so the `goto` below is no longer issued into that gap — see + // `withDocumentReplacement`. The retry stays: the claim here is that the + // record survives the round trip, not that one navigation attempt wins, + // and a loop that has nothing left to absorb costs a single pass. await leaveRecord(page); await expect(async () => { await page.goto("/settings/access"); 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/package.json b/package.json index 4aa16d7cd..97506c04f 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "knip": "^6.16.1", "lefthook": "^2.1.10", "pdf-parse": "^2.4.5", - "pdfjs-dist": "6.1.200", + "pdfjs-dist": "6.2.108", "pg": "^8.22.0", "prettier": "^3.9.1", "prettier-plugin-tailwindcss": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbe7c3e86..92dc14bec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: fast-uri@<3.1.5: ^3.1.5 ip-address@<10.3.1: ^10.3.1 sharp@<0.35.0: ^0.35.3 + pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108 importers: @@ -234,8 +235,8 @@ importers: specifier: ^2.4.5 version: 2.4.5 pdfjs-dist: - specifier: 6.1.200 - version: 6.1.200 + specifier: 6.2.108 + version: 6.2.108 pg: specifier: ^8.22.0 version: 8.22.0 @@ -1223,12 +1224,6 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@napi-rs/canvas-android-arm64@0.1.100': - resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - '@napi-rs/canvas-android-arm64@0.1.80': resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} engines: {node: '>= 10'} @@ -1241,12 +1236,6 @@ packages: cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.100': - resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@napi-rs/canvas-darwin-arm64@0.1.80': resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} engines: {node: '>= 10'} @@ -1259,12 +1248,6 @@ packages: cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.100': - resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.80': resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} engines: {node: '>= 10'} @@ -1277,12 +1260,6 @@ packages: cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': - resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} engines: {node: '>= 10'} @@ -1295,13 +1272,6 @@ packages: cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': - resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@napi-rs/canvas-linux-arm64-gnu@0.1.80': resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} engines: {node: '>= 10'} @@ -1316,13 +1286,6 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@0.1.100': - resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} @@ -1337,13 +1300,6 @@ packages: os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': - resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} @@ -1358,13 +1314,6 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@0.1.100': - resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} @@ -1379,13 +1328,6 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@0.1.100': - resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} @@ -1400,24 +1342,12 @@ packages: os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': - resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@napi-rs/canvas-win32-arm64-msvc@1.0.2': resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.100': - resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} engines: {node: '>= 10'} @@ -1430,10 +1360,6 @@ packages: cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.100': - resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} - engines: {node: '>= 10'} - '@napi-rs/canvas@0.1.80': resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} engines: {node: '>= 10'} @@ -5696,9 +5622,6 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - node-readable-to-web-readable-stream@0.4.2: - resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} - node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -5918,12 +5841,8 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} - pdfjs-dist@5.6.205: - resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} - engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} - - pdfjs-dist@6.1.200: - resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + pdfjs-dist@6.2.108: + resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==} engines: {node: '>=22.13.0 || >=24'} pend@1.2.0: @@ -8354,117 +8273,69 @@ snapshots: strict-event-emitter: 0.5.1 optional: true - '@napi-rs/canvas-android-arm64@0.1.100': - optional: true - '@napi-rs/canvas-android-arm64@0.1.80': optional: true '@napi-rs/canvas-android-arm64@1.0.2': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.100': - optional: true - '@napi-rs/canvas-darwin-arm64@0.1.80': optional: true '@napi-rs/canvas-darwin-arm64@1.0.2': optional: true - '@napi-rs/canvas-darwin-x64@0.1.100': - optional: true - '@napi-rs/canvas-darwin-x64@0.1.80': optional: true '@napi-rs/canvas-darwin-x64@1.0.2': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': - optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': optional: true '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': - optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-arm64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.100': - optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.80': optional: true '@napi-rs/canvas-linux-arm64-musl@1.0.2': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': - optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.100': - optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-x64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.100': - optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.80': optional: true '@napi-rs/canvas-linux-x64-musl@1.0.2': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': - optional: true - '@napi-rs/canvas-win32-arm64-msvc@1.0.2': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.100': - optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.80': optional: true '@napi-rs/canvas-win32-x64-msvc@1.0.2': optional: true - '@napi-rs/canvas@0.1.100': - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.100 - '@napi-rs/canvas-darwin-arm64': 0.1.100 - '@napi-rs/canvas-darwin-x64': 0.1.100 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 - '@napi-rs/canvas-linux-arm64-musl': 0.1.100 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-musl': 0.1.100 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 - '@napi-rs/canvas-win32-x64-msvc': 0.1.100 - optional: true - '@napi-rs/canvas@0.1.80': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.80 @@ -8707,7 +8578,7 @@ snapshots: fflate: 0.8.2 incur: 0.4.13 papaparse: 5.5.3 - pdfjs-dist: 5.6.205 + pdfjs-dist: 6.2.108 smol-toml: 1.6.1 transitivePeerDependencies: - '@types/node' @@ -12819,9 +12690,6 @@ snapshots: node-forge@1.4.0: {} - node-readable-to-web-readable-stream@0.4.2: - optional: true - node-releases@2.0.51: {} nodemailer@9.0.3: {} @@ -13084,12 +12952,7 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.80 - pdfjs-dist@5.6.205: - optionalDependencies: - '@napi-rs/canvas': 0.1.100 - node-readable-to-web-readable-stream: 0.4.2 - - pdfjs-dist@6.1.200: + pdfjs-dist@6.2.108: optionalDependencies: '@napi-rs/canvas': 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 15a8dafd2..c7fabca7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,14 @@ overrides: # rate limiter where nothing of ours screens it. "ip-address@<10.3.1": "^10.3.1" "sharp@<0.35.0": "^0.35.3" + # A crafted PDF could run arbitrary JavaScript through the renderer. Our own + # copy is pinned past it in `devDependencies`, but a second copy arrives + # transitively, and `outputFileTracingIncludes` ships EVERY `pdfjs-dist@*` in + # the store into the standalone image — so an unused transitive copy is still + # a copy in the runtime image. The range is bounded on both ends deliberately: + # `pdf-parse` resolves 5.4.296, which predates the flaw and drives the local + # text-extraction path, so it must not be dragged onto a new major. + "pdfjs-dist@>=5.6.83 <6.2.108": "6.2.108" allowBuilds: "@prisma/engines": true 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", )} >