From 315ca5e67ca6f75f0c63363b84193d30499fbcc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:35:23 +0200 Subject: [PATCH 01/11] fix(deps): move the PDF renderer past a script-execution flaw pdfjs-dist 6.1.200 and a transitive 5.6.205 both sit in the affected range of a flaw that lets a crafted PDF run JavaScript inside the renderer. The document vault feeds it every uploaded PDF, so pin the direct dependency to 6.2.108 and add a bounded override so the transitive copy lands there too. The override stops short of pdf-parse's 5.4.296, which predates the flaw and drives the local text-extraction path. The runtime image ships every pdfjs-dist copy in the store, not just the one that gets loaded, so an unused transitive copy still counted. Hoisting in the image picked whichever copy the filesystem listed first, and more than one copy reaches it, so the version named in package.json was not reliably the one the renderer resolved. Carry the resolved version forward from the build stage, select it by name, and fail the build when it is missing rather than leaving another copy in its place. --- CHANGELOG.md | 18 ++++++ Dockerfile | 36 +++++++++-- package.json | 2 +- pnpm-lock.yaml | 151 ++------------------------------------------ pnpm-workspace.yaml | 8 +++ 5 files changed, 64 insertions(+), 151 deletions(-) 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/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 From 34be5b45540f136198b34f801688dbda261ea6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:15:54 +0200 Subject: [PATCH 02/11] test: wait for a record switch to land before navigating away from it 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. The switcher helper returned there, and everything a test did next raced the app's own navigation. Both ways of losing that race are silent. A goto issued into the gap is cancelled when the assign commits, which Chromium reports as net::ERR_ABORTED and which is what CI saw twice. 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: /coach opened in that gap issued all four of the protected reads the test asserts never happen. The second is the worse outcome, because the assertion was reading the wrong record and 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 document on its way out already shows whatever the next assertion looks for, so the wait returns at once and proves nothing. A marker on window can settle it, because a document replacement is the one thing that destroys it. If the navigation never happens the wait fails, which is what a switch that did not land should do. Both helpers use it now, so the retry that was added downstream of leaving a record has nothing left to absorb; it stays, because the claim there is that the record survives the round trip rather than that one navigation attempt wins. Reproduced before fixing, under CPU contention at CI's worker count: three of six runs flaked, showing both faces. Six of six clean at the same contention afterwards, then twenty consecutive runs of the file and the whole sharing set, none flaky. --- e2e/v137-sharing-managed-profiles.spec.ts | 70 ++++++++++++++++++++--- 1 file changed, 61 insertions(+), 9 deletions(-) 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"); From eb5c8a91fbce94bdad58fe7345b30da2b7de9a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:23:31 +0200 Subject: [PATCH 03/11] fix(ui): keep date/time pickers inside their dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add and edit surfaces could be scrolled sideways and the time picker sat past the right edge: measurements, mood, the medication intake editor and the cycle log sheet all showed it, so the cause is in what they share. Two mechanisms, both in shared primitives. DateField and TimeField wrap a text input. A flex item's min-width:auto resolves to a content-based minimum, and for a text input that is its intrinsic size attribute — 20 characters, about 180px. Neither field could render below ~230px whatever width the parent offered, so the date + time row demanded ~470px inside a 400px dialog body and pushed the clock button 58px past the edge. min-w-0 on the field box and on the overlay input releases that floor; the row now fits at every width from 360px up. The scrolling body only declares overflow-y. CSS promotes the other axis from visible to auto, so every deliberate outward bleed in the tree turned the form into a pannable surface — SheetSection's trigger row was 8px wider than its own container for no benefit (a focus ring already draws outside the box), and Switch's hit-target pseudo reaches 13px past the track. The bodies and the dialog surface now state overflow-x explicitly, the section trigger no longer bleeds, and the dialog's grid column is pinned so an over-wide child cannot drag the surface out with it. Measured before and after on the four reported surfaces at 360 / 390 / 430 / 768 / 1280 / 1440: the date/time row spilled 83px on every width from 640px up and none of it remains. An e2e spec asserts the two harms directly — the surface is not a horizontal scroll container, and no focusable control lies outside its visible box — with a canary that proves the reachability check can fail. --- e2e/dialog-horizontal-overflow.spec.ts | 255 +++++++++++++++++++++++++ src/components/ui/date-field.tsx | 16 +- src/components/ui/date-time-field.tsx | 18 +- src/components/ui/dialog.tsx | 19 +- src/components/ui/responsive-sheet.tsx | 20 +- src/components/ui/sheet-section.tsx | 10 +- src/components/ui/time-field.tsx | 10 +- 7 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 e2e/dialog-horizontal-overflow.spec.ts diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts new file mode 100644 index 000000000..4382596e7 --- /dev/null +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -0,0 +1,255 @@ +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; + /** 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, + 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(); + // 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/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 35e8d9971..9eaa01f59 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -48,8 +48,16 @@ import { // only applies to the native value text — here no native input is rendered). const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; +// `min-w-0` is load-bearing, not cosmetic. A field is almost always a flex item +// (the date/time row, a form grid cell), and a flex item's `min-width: auto` +// resolves to its content-based minimum. The content here contains a text +// ``, whose intrinsic width comes from the `size` attribute's default of +// 20 characters (~180 px) — so without this the field refuses to render below +// ~230 px no matter what width the parent hands it, and the surplus spills out +// of whatever dialog or sheet it sits in. See the matching note on the overlay +// input below; both ends are needed, one alone still floors the box. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; + "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; export interface DateFieldProps { id?: string; @@ -182,7 +190,11 @@ export const DateField = React.forwardRef( inputMode="numeric" autoComplete="off" data-testid={dataTestId} - className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed" + // `min-w-0` releases the input's own automatic minimum size (the + // 20-character intrinsic width of a text input). Without it the + // input is the widest thing in the field and the field cannot + // shrink to the width its parent offers. + className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed" value={overlayValue} placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)} disabled={disabled} diff --git a/src/components/ui/date-time-field.tsx b/src/components/ui/date-time-field.tsx index cc34611c5..6cb58f308 100644 --- a/src/components/ui/date-time-field.tsx +++ b/src/components/ui/date-time-field.tsx @@ -110,13 +110,17 @@ export const DateTimeField = React.forwardRef<
` keeps its intrinsic width on mobile WebKit and won't - // shrink below it, so the `date + w-32 time` row overran a phone-width - // sheet (the time field + clock icon spilled ~70px past the edge, - // forcing a sideways scroll on every add form). Stacked, each half - // takes the full sheet width and nothing overflows. - "flex flex-col gap-2 sm:flex-row sm:items-stretch", + // only sit them side-by-side from `sm:` up. Stacked, each half takes + // the full sheet width, which is what a phone-width sheet can give. + // + // The side-by-side row above `sm:` needs `min-w-0` on both halves as + // well, and that lives in the field primitives themselves: each field + // wraps a text `` whose 20-character intrinsic width was the + // field's automatic minimum size, so `date + w-32 time` demanded + // ~470 px inside a 400 px dialog body and spilled past the edge on + // every add / edit form. `min-w-0` here lets the row itself shrink + // inside whatever grid or flex cell hosts it. + "flex min-w-0 flex-col gap-2 sm:flex-row sm:items-stretch", className, )} data-slot="date-time-field" diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 08472023c..c51604155 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -69,7 +69,24 @@ function DialogContent({ // viewport on short / soft-keyboard-collapsed mobile screens. // Consumers that need a different ceiling can override via // `className`. - "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", + // + // `grid-cols-[minmax(0,1fr)]` pins the implicit column. A bare + // `grid` gives its single column an `auto` track, which sizes to the + // widest child's max-content and grows the box past the dialog's own + // `max-w-*` — and because `overflow-y:auto` makes `overflow-x` + // compute to `auto`, that surplus becomes a horizontal scrollbar + // rather than a visible spill. `minmax(0,1fr)` caps the track at the + // dialog width so an over-wide child is contained instead of + // dragging the surface out with it. + // + // `overflow-x-clip` pairs with it: `overflow-y-auto` alone promotes + // the other axis from `visible` to `auto`, so a single over-wide + // child made the whole dialog pannable sideways. A dialog scrolls + // vertically; anything that genuinely needs sideways scroll brings + // its own `overflow-x-auto` wrapper. Consumers that set `overflow-*` + // themselves still win — `` passes + // `overflow-hidden` and scrolls its body instead. + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] grid-cols-[minmax(0,1fr)] gap-4 overflow-x-clip overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", className, )} {...props} diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 743470921..9cb7a24a7 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -220,7 +220,10 @@ export function ResponsiveSheet({
@@ -304,7 +307,20 @@ export function ResponsiveSheet({
`'s deliberate 4 px hover bleed + // (`-mx-1 w-[calc(100%+0.5rem)]`) both produced a horizontal + // scrollbar on the desktop dialog. `clip` is not `visible`, so the + // promotion does not happen and the vertical scroll is kept. + // + // Content that genuinely needs to scroll sideways (a wide table) + // owns its own `overflow-x-auto` wrapper — that is the correct + // place for it, and it still works inside a clipped port. + "flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto", bodyClassName, )} > diff --git a/src/components/ui/sheet-section.tsx b/src/components/ui/sheet-section.tsx index bd865124a..b93246092 100644 --- a/src/components/ui/sheet-section.tsx +++ b/src/components/ui/sheet-section.tsx @@ -60,7 +60,15 @@ export function SheetSection({ > {icon ? ( ` and carries the same reason: a flex item's +// automatic minimum size would otherwise be the 20-character intrinsic width of +// the text input inside, which floors the whole field at ~230 px and overflows +// the dialog that hosts it. See the note in `date-field.tsx`. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; + "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -153,7 +157,9 @@ export const TimeField = React.forwardRef( inputMode="numeric" autoComplete="off" data-testid={dataTestId} - className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed" + // See `date-field.tsx`: without `min-w-0` the input's intrinsic + // 20-character width becomes the field's floor. + className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed" value={overlayValue} placeholder={placeholder ?? (use12h ? "--:-- --" : "--:--")} disabled={disabled} From 82490870e56cbbcee458ea2d3f7a88cba7f0227c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:29:58 +0200 Subject: [PATCH 04/11] fix(coach): make the launcher's dot mean a message you can read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach button showed its unread dot over an empty conversation, and opening the Coach neither showed anything nor cleared the dot. Two paths fed one indicator. The nightly sweep flipped a "remind me" reminder from active to due and stopped there — no conversation, no message, and `surfaced` had no writer at all. The launcher lit its dot off a second query that asked only whether anything was due. So the dot promised content that was never written, and because opening the Coach clears the message-based signal and not the reminder one, the only way out was to resolve the reminder in Settings, which nothing on the badge suggested. A reminder minted for a plan review put a user who had never typed in the Coach in exactly that state. The sweep now writes the reminder into the user's Coach thread as a real assistant message and marks it surfaced in the same transaction: no message, no status change, and nothing to point at. The badge derives from that message like every other proactive one, so opening the Coach shows the note and clears the dot. The launcher's second query is gone. Reminders an older build left stuck on due are picked up by the same pass, so an indicator that has been sitting on an empty conversation resolves into the message it always implied. No provider call and no push: the body is a deterministic localized template around the user's own note text, six languages. Proven end to end against Postgres — the row exists, the badge reads that row, opening clears it, a second tick writes nothing, and a failed write leaves the reminder untouched with the badge dark. All five fail against the previous sweep. The second indicator in the report, under the bottom bar's overflow entry, is the active-route marker for that group and not a badge. Refs #715 --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + src/components/insights/layout-coach-fab.tsx | 31 +-- .../__tests__/coach-reminder-sweep.test.ts | 219 +++++++++++----- src/lib/jobs/coach-reminder-sweep.ts | 189 ++++++++++++-- src/lib/jobs/reminder/register-status.ts | 10 +- .../coach-reminder-surfacing.test.ts | 239 ++++++++++++++++++ 11 files changed, 594 insertions(+), 112 deletions(-) create mode 100644 tests/integration/coach-reminder-surfacing.test.ts diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..be2abd1b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..a4745ee09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index a800a3727..a2d577662 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..6a3e7939e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..be11085ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..7b0cbd0af 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 0d652d85e..19bc4f3c4 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -122,27 +122,20 @@ export function LayoutCoachFab() { }, }); - // v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive: - // the dot also lights when the daily sweep has flipped a "remind me" reminder - // to due, independent of the nudge seen-stamp (resolving the reminder in the - // ledger clears it). Same cheap, gated, stale-while-revalidate read shape. - const { data: dueReminders } = useQuery({ - queryKey: queryKeys.coachReminders("due,surfaced"), - queryFn: async () => { - const data = await apiGet<{ reminders?: unknown[] } | undefined>( - "/api/coach/reminders?status=due,surfaced", - ); - return data?.reminders ?? []; - }, - enabled: coachAvailable && !onCoachPage, - staleTime: 5 * 60 * 1000, - }); - const hasDueReminders = (dueReminders?.length ?? 0) > 0; - const nudgedAt = status?.nudgedAt ?? null; + // ONE source for the dot: an assistant message the user has not opened yet. + // + // v1.22 added a second arm — a separate "any due reminder?" query — and that + // is the arm that produced a badge over an empty Coach. The sweep flipped a + // reminder to `due` without writing anything into a conversation, so the dot + // promised a message that did not exist; opening the Coach showed the empty + // state and did not clear the dot either, because only resolving the reminder + // in Settings could. The sweep now writes the reminder into the thread as a + // real assistant message (`coach-reminder-sweep.ts`), which the nudge status + // already reports — so the reminder still lights the dot, through the message + // it actually wrote, and opening the Coach clears it like any other. const unread = isNudgeUnread(status, seenStamp); - // The visual dot lights for either an unread nudge OR a due reminder. - const showDot = unread || hasDueReminders; + const showDot = unread; // The unread dot is visual-only (`aria-hidden`) and the swapped // `aria-label` is not announced on mutation — a screen-reader user diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index ecb93983d..fee4c27f4 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -1,10 +1,12 @@ /** * v1.22 (M4) — daily Coach-reminder sweep. * - * Covers: overdue active date-reminders flip to `due`; a passed CoachPlan - * reviewDate mints a one-off reminder from the plan's own cue→action text and - * clears the reviewDate (activating the dangling B1 column); an undecryptable - * plan is skipped (counted errored) without sinking the tick. + * Covers: a passed CoachPlan reviewDate mints a one-off reminder from the + * plan's own cue→action text and clears the reviewDate (activating the dangling + * B1 column); an undecryptable plan is skipped (counted errored) without + * sinking the tick; and — v1.37 — surfacing writes the reminder into a + * conversation and flips its status in one transaction, so a badge can never + * point at a message that was never written. */ import { describe, it, expect, vi } from "vitest"; @@ -25,84 +27,179 @@ function bytes(tag: string): Uint8Array { return new Uint8Array(Buffer.from(tag, "utf8")); } +/** + * A prisma double whose `$transaction` accepts BOTH shapes the sweep uses: an + * array of pre-built operations (the plan-review mint) and an interactive + * callback (the surfacing step). + */ +function makePrisma(options: { + overdue?: { + id: string; + userId: string; + noteEncrypted: Uint8Array; + }[]; + plans?: { + id: string; + userId: string; + metric: string; + ifCueEncrypted: Uint8Array; + thenActionEncrypted: Uint8Array; + }[]; +}) { + const tx = { + coachConversation: { create: vi.fn(async () => ({ id: "c1" })) }, + coachMessage: { create: vi.fn(async () => ({ id: "m1" })) }, + coachReminder: { updateMany: vi.fn(async () => ({ count: 0 })) }, + }; + const prisma = { + coachReminder: { + findMany: vi.fn(async () => options.overdue ?? []), + create: vi.fn(async () => ({ id: "r-new" })), + updateMany: vi.fn(async () => ({ count: 0 })), + }, + coachPlan: { + findMany: vi.fn(async () => options.plans ?? []), + update: vi.fn(async () => ({})), + }, + coachConversation: { create: vi.fn(async () => ({ id: "c1" })) }, + coachMessage: { create: vi.fn(async () => ({ id: "m1" })) }, + user: { findUnique: vi.fn(async () => ({ locale: "en" })) }, + $transaction: vi.fn(async (arg: unknown) => { + if (typeof arg === "function") { + return (arg as (t: typeof tx) => Promise)(tx); + } + return Promise.all(arg as Promise[]); + }), + tx, + }; + return prisma; +} + describe("runCoachReminderSweep", () => { - it("flips overdue active date-reminders to due and mints plan-review reminders", async () => { - const prisma = { - coachReminder: { - updateMany: vi.fn(async () => ({ count: 3 })), - create: vi.fn(async () => ({ id: "r-new" })), - }, - coachPlan: { - findMany: vi.fn(async () => [ - { - id: "p1", - userId: "u1", - metric: "WEIGHT", - ifCueEncrypted: bytes("every morning"), - thenActionEncrypted: bytes("weigh in"), - }, - ]), - update: vi.fn(async () => ({})), - }, - // The mint runs as one atomic batch; resolve the built ops together. - $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), - }; + it("mints a plan-review reminder from the plan's own prose and clears the reviewDate", async () => { + const prisma = makePrisma({ + plans: [ + { + id: "p1", + userId: "u1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + }, + ], + }); const summary = await runCoachReminderSweep(prisma as never, NOW); - expect(summary.remindersDue).toBe(3); expect(summary.planReviewsMinted).toBe(1); expect(summary.errored).toBe(0); - // The flip query is scoped to active, date-triggered, overdue rows. - const flipArgs = prisma.coachReminder.updateMany.mock - .calls[0] as unknown as [ - { where: { status: string; triggerKind: string; dueAt: unknown } }, - ]; - const where = flipArgs[0].where; - expect(where.status).toBe("active"); - expect(where.triggerKind).toBe("date"); - expect(where.dueAt).toEqual({ not: null, lte: NOW }); - - // The minted reminder carries the plan's own prose + relatedPlanId. const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [ { data: { relatedPlanId: string; source: string; status: string } }, ]; const data = createArgs[0].data; expect(data.relatedPlanId).toBe("p1"); expect(data.source).toBe("extractor"); - expect(data.status).toBe("due"); + // Minted `active`, not `due`: the surfacing pass in this same tick is what + // takes it to the conversation, and there is only one such path. + expect(data.status).toBe("active"); - // The plan's reviewDate is cleared so the review fires exactly once. expect(prisma.coachPlan.update).toHaveBeenCalledWith({ where: { id: "p1" }, data: { reviewDate: null }, }); + }); - // The create + clear commit atomically — a half-applied mint would let the - // next tick re-select the plan and duplicate the review reminder. - expect(prisma.$transaction).toHaveBeenCalledTimes(1); + it("writes the reminder into a conversation and flips its status in ONE transaction", async () => { + const prisma = makePrisma({ + overdue: [ + { id: "r1", userId: "u1", noteEncrypted: bytes("ask about my sleep") }, + ], + }); + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(0); + + // A conversation with the note as an ASSISTANT message — the row the + // unread badge is derived from. + expect(prisma.tx.coachConversation.create).toHaveBeenCalledTimes(1); + const msgArgs = prisma.tx.coachMessage.create.mock.calls[0] as unknown as [ + { data: { role: string; encryptedContent: Uint8Array } }, + ]; + expect(msgArgs[0].data.role).toBe("assistant"); + const body = Buffer.from(msgArgs[0].data.encryptedContent).toString("utf8"); + expect(body).toContain("dec:ask about my sleep"); + + // …and the status flip rides the SAME transaction, so neither half can + // land without the other. + const flip = prisma.tx.coachReminder.updateMany.mock + .calls[0] as unknown as [ + { where: { id: { in: string[] } }; data: { status: string } }, + ]; + expect(flip[0].where.id.in).toEqual(["r1"]); + expect(flip[0].data.status).toBe("surfaced"); + }); + + it("picks up rows an older build left stuck on `due`", async () => { + const prisma = makePrisma({ + overdue: [{ id: "r-old", userId: "u1", noteEncrypted: bytes("stuck") }], + }); + + await runCoachReminderSweep(prisma as never, NOW); + + const where = ( + prisma.coachReminder.findMany.mock.calls[0] as unknown as [ + { where: { status: { in: string[] } } }, + ] + )[0].where; + expect(where.status.in).toEqual(["active", "due"]); + }); + + it("leaves the reminder alone when the conversation write fails", async () => { + const prisma = makePrisma({ + overdue: [{ id: "r1", userId: "u1", noteEncrypted: bytes("note") }], + }); + prisma.$transaction = vi.fn(async (arg: unknown) => { + if (typeof arg === "function") throw new Error("db down"); + return Promise.all(arg as Promise[]); + }) as never; + + const summary = await runCoachReminderSweep(prisma as never, NOW); + // Not counted as surfaced — the next tick retries, and until it succeeds + // nothing claims a message that does not exist. + expect(summary.remindersDue).toBe(0); + expect(summary.errored).toBe(1); + expect(prisma.tx.coachReminder.updateMany).not.toHaveBeenCalled(); + }); + + it("skips an undecryptable note without losing the ones beside it", async () => { + const prisma = makePrisma({ + overdue: [ + { id: "r-bad", userId: "u1", noteEncrypted: bytes("__bad__") }, + { id: "r-ok", userId: "u1", noteEncrypted: bytes("fine") }, + ], + }); + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(1); + const flip = prisma.tx.coachReminder.updateMany.mock + .calls[0] as unknown as [{ where: { id: { in: string[] } } }]; + expect(flip[0].where.id.in).toEqual(["r-ok"]); }); it("skips an undecryptable plan without sinking the tick", async () => { - const prisma = { - coachReminder: { - updateMany: vi.fn(async () => ({ count: 0 })), - create: vi.fn(async () => ({ id: "r-new" })), - }, - coachPlan: { - findMany: vi.fn(async () => [ - { - id: "p1", - userId: "u1", - metric: "SLEEP", - ifCueEncrypted: bytes("__bad__"), - thenActionEncrypted: bytes("lights out"), - }, - ]), - update: vi.fn(async () => ({})), - }, - $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), - }; + const prisma = makePrisma({ + plans: [ + { + id: "p1", + userId: "u1", + metric: "SLEEP", + ifCueEncrypted: bytes("__bad__"), + thenActionEncrypted: bytes("lights out"), + }, + ], + }); const summary = await runCoachReminderSweep(prisma as never, NOW); expect(summary.planReviewsMinted).toBe(0); diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index 136bf23fe..010bde5be 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -5,8 +5,7 @@ * per tick that brings a reminder back at its chosen moment. Two sources: * * 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt` - * has passed → flipped to `due` (the in-app tile + indicator read `due` / - * `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped. + * has passed. * 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read * nowhere until now — see the design §1c) has passed and that are still * `active` → a one-off `CoachReminder` is minted from the plan's own @@ -14,9 +13,30 @@ * `reviewDate` is cleared so the review fires once. This finally activates * the column the B1 schema anticipated. * - * In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4) - * land later. The sweep never calls a provider and never pushes — it only flips - * status + mints the plan-review reminder, both idempotent across re-fires. + * Both sources converge on ONE surfacing step, and that convergence is the + * point. Until v1.37 the sweep only flipped a reminder's status to `due` and + * stopped: nothing was written into the conversation, `surfaced` had no writer + * at all, and the FAB lit its unread dot off a separate "is anything due?" + * query. So the dot appeared over an empty Coach, opening it showed the empty + * state, and opening it did not clear the dot either — the only way out was to + * resolve the reminder in Settings, which nothing on the badge suggested. A + * badge and the content it promises must come from one event. + * + * They do now. Surfacing writes the reminder into the user's Coach thread as a + * real assistant message and flips the reminder to `surfaced` in the SAME + * transaction, so the two cannot drift: no message, no status change, no + * badge. The badge itself is derived from that message + * (`readCoachNudgeStatus` compares the newest assistant message against + * `User.coachLastSeenAt`), which means opening the Coach clears it the way it + * clears any other proactive message. + * + * Rows left `due` by an older build are picked up by the same pass, so a badge + * that has been stuck on an empty conversation resolves into the message it + * always implied rather than staying stuck. + * + * The sweep still never calls a provider and never pushes. The message body is + * a deterministic localized template around the user's OWN note text — the + * words they asked to be reminded of, never anything fabricated. * * Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that * is a follow-on (F4); the column exists and capture supports it, but the date @@ -25,6 +45,9 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; // Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both @@ -33,8 +56,13 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; /** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */ const PLAN_REVIEW_BATCH = 200; +/** Bound the surfacing fan-out per tick for the same reason. */ +const SURFACE_BATCH = 500; +/** How many notes one surfacing message carries before the rest wait a tick. */ +const MAX_NOTES_PER_MESSAGE = 6; export interface CoachReminderSweepSummary { + /** Reminders whose moment arrived AND that reached the conversation. */ remindersDue: number; planReviewsMinted: number; errored: number; @@ -42,12 +70,43 @@ export interface CoachReminderSweepSummary { type SweepPrisma = Pick< PrismaClient, - "coachReminder" | "coachPlan" | "$transaction" + | "coachReminder" + | "coachPlan" + | "coachConversation" + | "coachMessage" + | "user" + | "$transaction" >; +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + /** - * Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped, - * and a plan whose `reviewDate` was cleared on a prior tick is not re-minted. + * The surfacing message for one user's due reminders. Deterministic, localized, + * and built only from the user's own note text — the sweep composes, it never + * generates. + */ +export function buildReminderSurfaceMessage( + notes: readonly string[], + locale: string | null | undefined, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + const title = t("coachNudges.reminderSurfacedTitle"); + const lead = + notes.length === 1 + ? t("coachNudges.reminderSurfacedLeadOne") + : t("coachNudges.reminderSurfacedLeadMany", { count: notes.length }); + const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n"); + return { title, body }; +} + +/** + * Run one sweep tick. Idempotent: a reminder already `surfaced` is not + * re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not + * re-minted. */ export async function runCoachReminderSweep( prisma: SweepPrisma, @@ -59,23 +118,10 @@ export async function runCoachReminderSweep( errored: 0, }; - // ── 1. flip overdue active date-reminders → due ────────────── - const due = await prisma.coachReminder.updateMany({ - where: { - deletedAt: null, - status: "active", - triggerKind: "date", - dueAt: { not: null, lte: now }, - }, - data: { - status: "due", - lastSurfacedAt: now, - surfaceCount: { increment: 1 }, - }, - }); - summary.remindersDue = due.count; - - // ── 2. mint plan-review reminders for passed reviewDates ────── + // ── 1. mint plan-review reminders for passed reviewDates ────── + // + // Minted `active` with `dueAt: now` so the surfacing pass below picks them + // up in this same tick. One path reaches the conversation, not two. const plans = await prisma.coachPlan.findMany({ where: { deletedAt: null, @@ -120,10 +166,8 @@ export async function runCoachReminderSweep( relatedPlanId: plan.id, triggerKind: "date", dueAt: now, - status: "due", + status: "active", source: "extractor", - lastSurfacedAt: now, - surfaceCount: 1, }, }), prisma.coachPlan.update({ @@ -137,5 +181,94 @@ export async function runCoachReminderSweep( } } + // ── 2. surface everything whose moment has passed ───────────── + // + // `due` is included alongside `active` deliberately: rows an older build + // flipped to `due` never reached a conversation, and they are exactly the + // ones showing a badge with nothing behind it. Picking them up here converts + // a stuck indicator into the message it always promised. + const overdue = await prisma.coachReminder.findMany({ + where: { + deletedAt: null, + status: { in: ["active", "due"] }, + triggerKind: "date", + dueAt: { not: null, lte: now }, + }, + take: SURFACE_BATCH, + orderBy: { dueAt: "asc" }, + select: { id: true, userId: true, noteEncrypted: true }, + }); + + const byUser = new Map(); + for (const row of overdue) { + const bucket = byUser.get(row.userId); + if (bucket) bucket.push(row); + else byUser.set(row.userId, [row]); + } + + for (const [userId, rows] of byUser) { + try { + // Decrypt fault-isolated per row: one undecryptable note must not cost + // the user the reminders that sit beside it. + const usable: { id: string; note: string }[] = []; + for (const row of rows) { + try { + usable.push({ + id: row.id, + note: decryptFromBytes(row.noteEncrypted), + }); + } catch { + summary.errored += 1; + } + } + if (usable.length === 0) continue; + + const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const { title, body } = buildReminderSurfaceMessage( + batch.map((r) => r.note), + user?.locale, + ); + + // Conversation + message + status flip in ONE transaction. A partial + // write is the failure this whole change exists to remove: a status + // without a message is a badge pointing at nothing, and a message + // without a status change re-surfaces the same note every night. + await prisma.$transaction(async (tx) => { + const conversation = await tx.coachConversation.create({ + data: { userId, title: title.slice(0, 80) }, + }); + await tx.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes(body), + metricSourceJson: null, + // Same tag the proactive nudge uses — this IS a proactive message, + // and the nudge cron's frequency gate reads the tag back. + providerType: "nudge", + promptVersion: null, + }, + }); + await tx.coachReminder.updateMany({ + where: { id: { in: batch.map((r) => r.id) } }, + data: { + status: "surfaced", + lastSurfacedAt: now, + surfaceCount: { increment: 1 }, + }, + }); + }); + summary.remindersDue += batch.length; + } catch { + // Leave the rows where they were: the next tick retries, and until then + // no badge claims a message that was never written. + summary.errored += 1; + } + } + return summary; } diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 9ebca0bc2..3771f9ab2 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -822,10 +822,12 @@ export async function registerStatusQueues( }, ); - // v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue - // reminder to `due` and minting plan-review reminders are both idempotent - // across re-fires (an already-due reminder is not re-flipped; a plan whose - // reviewDate was cleared is not re-minted). No provider call, no push. + // v1.22 (M4) — daily Coach-reminder sweep. Single-flight; surfacing an + // overdue reminder into the user's Coach thread and minting plan-review + // reminders are both idempotent across re-fires (an already-surfaced + // reminder is not re-written; a plan whose reviewDate was cleared is not + // re-minted). No provider call, no push — the message body is a + // deterministic localized template around the user's own note. await createAndWork( boss, COACH_REMINDER_SWEEP_QUEUE, diff --git a/tests/integration/coach-reminder-surfacing.test.ts b/tests/integration/coach-reminder-surfacing.test.ts new file mode 100644 index 000000000..cbd927b0a --- /dev/null +++ b/tests/integration/coach-reminder-surfacing.test.ts @@ -0,0 +1,239 @@ +/** + * The Coach badge and the Coach conversation, over the real assembly. + * + * The report: the FAB shows its unread dot, you open the Coach, the + * conversation is empty, and the dot is still there afterwards. Two separate + * paths were feeding one indicator — a "is any reminder due?" query lit the + * dot, while the nightly sweep that made the reminder due wrote nothing into a + * conversation and had no way to be cleared by opening one. + * + * The unit suite can prove the sweep CALLS the right prisma methods; it cannot + * prove a row exists, that the badge derives from that row, or that opening + * the Coach clears it. Those three claims are the defect, so they are asserted + * here against Postgres, through the same functions the cron and the FAB use: + * `runCoachReminderSweep` writes, `readCoachNudgeStatus` reads, and the seen + * stamp in between is the one `POST /api/insights/coach/seen` writes. + * + * The note text is round-tripped through the real AES codec rather than a + * mock, so an encoding mistake between the sweep and the conversation reader + * fails here instead of shipping as an unreadable reminder. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep"; +import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +const NOW = new Date("2026-08-07T05:20:00.000Z"); +const YESTERDAY = new Date("2026-08-06T09:00:00.000Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seedUser(username: string, locale = "en") { + return getPrismaClient().user.create({ + data: { + username, + email: `${username}@example.test`, + timezone: "UTC", + locale, + }, + }); +} + +describe("a due Coach reminder reaches the conversation the badge points at", () => { + it("writes the note as an assistant message, and that message is what makes the badge unread", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-surfacing"); + + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("ask me about my sleep again"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + // Before the sweep there is nothing to be unread ABOUT. + const before = await readCoachNudgeStatus(user.id); + expect(before.unread).toBe(false); + expect(before.conversationId).toBeNull(); + + const summary = await runCoachReminderSweep(prisma, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(0); + + // BOTH ENDS. The row exists… + const messages = await prisma.coachMessage.findMany({ + where: { conversation: { userId: user.id } }, + }); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("assistant"); + expect(decryptFromBytes(messages[0].encryptedContent)).toContain( + "ask me about my sleep again", + ); + + // …and the badge is derived from THAT row, not from a parallel query. + const after = await readCoachNudgeStatus(user.id); + expect(after.unread).toBe(true); + expect(after.conversationId).toBe(messages[0].conversationId); + + // The reminder is marked surfaced, so it stops being pending work. + const reminder = await prisma.coachReminder.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(reminder.status).toBe("surfaced"); + expect(reminder.surfaceCount).toBe(1); + }); + + it("opening the Coach clears the badge", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-seen"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("check the blood pressure trend"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + await runCoachReminderSweep(prisma, NOW); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); + + // What `POST /api/insights/coach/seen` writes when the Coach is opened. + // Anchored on the MESSAGE's own `createdAt`: the sweep takes its `now` for + // the due-date arithmetic, but the row is stamped by the database, and the + // route stamps the seen time from the same clock the row came from. + const written = await prisma.coachMessage.findFirstOrThrow({ + where: { conversation: { userId: user.id } }, + }); + await prisma.user.update({ + where: { id: user.id }, + data: { coachLastSeenAt: new Date(written.createdAt.getTime() + 1_000) }, + }); + + expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); + }); + + it("does not surface the same reminder twice", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-idempotent"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("weekly review"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + await runCoachReminderSweep(prisma, NOW); + const second = await runCoachReminderSweep( + prisma, + new Date(NOW.getTime() + 86_400_000), + ); + + expect(second.remindersDue).toBe(0); + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(1); + }); + + it("takes a reminder an older build left stuck on `due` and gives it the message it promised", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-legacy"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("the note nobody ever saw"), + triggerKind: "date", + dueAt: new Date("2026-05-01T09:00:00.000Z"), + status: "due", + lastSurfacedAt: new Date("2026-05-01T05:20:00.000Z"), + surfaceCount: 1, + source: "user", + }, + }); + + // This is the reported state exactly: something the UI treated as + // outstanding, with an empty conversation behind it. + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(0); + + await runCoachReminderSweep(prisma, NOW); + + const messages = await prisma.coachMessage.findMany({ + where: { conversation: { userId: user.id } }, + }); + expect(messages).toHaveLength(1); + expect(decryptFromBytes(messages[0].encryptedContent)).toContain( + "the note nobody ever saw", + ); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); + }); + + it("keeps a failed write from leaving a badge with nothing behind it", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-partial"); + const reminder = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("intact note"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + // Fail the transaction the way a real outage would — inside the write, so + // the conversation, the message and the status flip are all in flight. + const failing = new Proxy(prisma, { + get(target, prop, receiver) { + if (prop === "$transaction") { + return (arg: unknown) => { + if (typeof arg === "function") { + return Promise.reject(new Error("connection reset")); + } + const passthrough = Reflect.get(target, prop, receiver) as ( + ops: unknown, + ) => Promise; + return passthrough.call(target, arg); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + + const summary = await runCoachReminderSweep(failing as typeof prisma, NOW); + expect(summary.remindersDue).toBe(0); + expect(summary.errored).toBe(1); + + // Nothing written, nothing marked — and the badge stays dark, which is the + // honest state: there is no message to read. + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(0); + const untouched = await prisma.coachReminder.findFirstOrThrow({ + where: { id: reminder.id }, + }); + expect(untouched.status).toBe("active"); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); + }); +}); From 06dd7343c25e38d049e37e8dcc94476990ac4472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:31:10 +0200 Subject: [PATCH 05/11] fix(settings): point the empty delivery-status card at a place, not a direction The card told people to add a channel "below". A position is only true while nothing above or below it moves, and the same card already reads as the first thing in its group, so "below" says less than the group's own name does. It now names Delivery channels, which stays true wherever the card is rendered. The card's title and its per-channel description were still English in Spanish, French, Italian and Polish while the strings around them were translated; they are translated now too. Six languages. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 6 +++--- messages/fr.json | 6 +++--- messages/it.json | 6 +++--- messages/pl.json | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index be2abd1b4..06e3486e0 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", diff --git a/messages/en.json b/messages/en.json index a4745ee09..e98bbac76 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", diff --git a/messages/es.json b/messages/es.json index a2d577662..04f190b8e 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", diff --git a/messages/fr.json b/messages/fr.json index 6a3e7939e..789eeac87 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", diff --git a/messages/it.json b/messages/it.json index be11085ae..7d96bcfa8 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", diff --git a/messages/pl.json b/messages/pl.json index 7b0cbd0af..ee147d4e3 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", From 72cd21352c7c27f6066c1721ba7659b45c87d4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:36:31 +0200 Subject: [PATCH 06/11] refactor(mental-wellbeing): shorten the intro and drop the mood-history link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intro listed all four instruments with their subject in brackets and then explained itself, which ran to two lines on a desktop header and five or six on a phone — a paragraph to read before reaching the four cards that say the same thing. It keeps its opening claim and the one caveat that matters, and loses the enumeration the cards below already carry: one line on desktop in all six languages, measured at 1280 and 1440. The pointer to mood history at the foot of the page is gone. The link on the mood page pointing here stays, because that direction earns its keep: someone logging a mood may not know the screeners exist, while someone who came here for a screener knows where their diary is. --- messages/de.json | 3 +-- messages/en.json | 3 +-- messages/es.json | 3 +-- messages/fr.json | 3 +-- messages/it.json | 3 +-- messages/pl.json | 3 +-- .../mental-health/mental-wellbeing.tsx | 19 +++++-------------- 7 files changed, 11 insertions(+), 26 deletions(-) diff --git a/messages/de.json b/messages/de.json index 06e3486e0..f6f5cad7d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8589,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", diff --git a/messages/en.json b/messages/en.json index e98bbac76..c4ca0124b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8589,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", diff --git a/messages/es.json b/messages/es.json index 04f190b8e..cae29080d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8589,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", diff --git a/messages/fr.json b/messages/fr.json index 789eeac87..f7904933b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8589,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", diff --git a/messages/it.json b/messages/it.json index 7d96bcfa8..a45b72862 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8589,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", diff --git a/messages/pl.json b/messages/pl.json index ee147d4e3..d39b0a380 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8589,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", diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 702865322..b33f3902f 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -28,9 +28,7 @@ * the page someone arrives at to take stock. */ import { useMemo, useState } from "react"; -import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowRight } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; @@ -179,18 +177,11 @@ export function MentalWellbeing() { )} - {/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface, - and the mood insights page form one mental-health domain but used - to be three unconnected islands. A quiet pointer to mood history, - mirroring the one added on `/mood` pointing back here. */} - - {t("mentalHealth.moodLink")} -
-

- {t("settings.avatar.hint")} -

{msg && (

Date: Fri, 7 Aug 2026 16:39:50 +0200 Subject: [PATCH 08/11] fix(coach): centre the composer text in its input pill On a phone the placeholder sat noticeably low. The composer row was bottom-aligned so the send button would stay beside the last line as the textarea grows, but that also bottom-aligned a single-line textarea against a 44 px touch target: 7 px of space above the text and 1 px below. The row centres its textarea now and the controls keep their own bottom alignment, so both properties hold. Measured empty, one line and four lines, phone and desktop: the single-line gap goes 7/1 to 4/4 on a phone and 2/1 to 2/2 on desktop, and the multi-line case is unchanged with the send button still level with the last line. --- .../coach-panel/__tests__/coach-input.test.tsx | 10 +++++++--- .../insights/coach-panel/coach-input.tsx | 14 ++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) 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", )} >