From 41ba4c6c179f698d6cfe8f8e18d601508643e099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 12:55:55 +0200 Subject: [PATCH 01/21] chore(deps): bump simplewebauthn, react-query, react-hook-form, types/node, knip, login-action @simplewebauthn/server 13.3.1, @tanstack/react-query 5.101.0, react-hook-form 7.77.0, @types/node 25.9.2, knip 6.16.1 and docker/login-action v4 across the workflows. --- .github/workflows/docker-publish.yml | 4 +- .github/workflows/post-publish-verify.yml | 2 +- package.json | 10 +- pnpm-lock.yaml | 497 +++++++++++----------- 4 files changed, 248 insertions(+), 265 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0d32ac4c7..3e82b3718 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -93,7 +93,7 @@ jobs: # Image is built but not pushed in that case. - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -267,7 +267,7 @@ jobs: echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 30ccee8c1..33756991c 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -70,7 +70,7 @@ jobs: fi - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/package.json b/package.json index fd98f0e4c..140dd5235 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,8 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.0", - "@tanstack/react-query": "^5.100.10", + "@simplewebauthn/server": "^13.3.1", + "@tanstack/react-query": "^5.101.0", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -78,7 +78,7 @@ "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-hook-form": "^7.76.1", + "react-hook-form": "^7.77.0", "recharts": "^3.8.1", "rrule": "^2.8.1", "sax": "^1.6.0", @@ -97,7 +97,7 @@ "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@testcontainers/postgresql": "^12.0.1", - "@types/node": "^25", + "@types/node": "^25.9.2", "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -106,7 +106,7 @@ "@vitest/coverage-v8": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.2.6", - "knip": "^6.14.2", + "knip": "^6.16.1", "pdf-parse": "^2.4.5", "pg": "^8.21.0", "prettier": "^3.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a4840041..87f6dcc29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 3.2.2(react@19.2.5) '@hookform/resolvers': specifier: ^5.4.0 - version: 5.4.0(react-hook-form@7.76.1(react@19.2.5)) + version: 5.4.0(react-hook-form@7.77.0(react@19.2.5)) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -39,11 +39,11 @@ importers: specifier: ^13.3.0 version: 13.3.0 '@simplewebauthn/server': - specifier: ^13.3.0 - version: 13.3.0 + specifier: ^13.3.1 + version: 13.3.1 '@tanstack/react-query': - specifier: ^5.100.10 - version: 5.100.10(react@19.2.5) + specifier: ^5.101.0 + version: 5.101.0(react@19.2.5) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -96,8 +96,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-hook-form: - specifier: ^7.76.1 - version: 7.76.1(react@19.2.5) + specifier: ^7.77.0 + version: 7.77.0(react@19.2.5) recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1) @@ -148,8 +148,8 @@ importers: specifier: ^12.0.1 version: 12.0.1 '@types/node': - specifier: ^25 - version: 25.9.1 + specifier: ^25.9.2 + version: 25.9.2 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -164,7 +164,7 @@ importers: version: 3.6.4 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.1.4(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.5) @@ -175,8 +175,8 @@ importers: specifier: 16.2.6 version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3) knip: - specifier: ^6.14.2 - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + specifier: ^6.16.1 + version: 6.16.1 pdf-parse: specifier: ^2.4.5 version: 2.4.5 @@ -191,7 +191,7 @@ importers: version: 0.8.0(prettier@3.8.3) shadcn: specifier: ^4.9.0 - version: 4.9.0(@types/node@25.9.1)(typescript@6.0.3) + version: 4.9.0(@types/node@25.9.2)(typescript@6.0.3) tailwindcss: specifier: ^4 version: 4.3.0 @@ -206,7 +206,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) packages: @@ -1387,241 +1387,236 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-parser/binding-android-arm-eabi@0.130.0': - resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==} + '@oxc-parser/binding-android-arm-eabi@0.133.0': + resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.130.0': - resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==} + '@oxc-parser/binding-android-arm64@0.133.0': + resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.130.0': - resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==} + '@oxc-parser/binding-darwin-arm64@0.133.0': + resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.130.0': - resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==} + '@oxc-parser/binding-darwin-x64@0.133.0': + resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.130.0': - resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==} + '@oxc-parser/binding-freebsd-x64@0.133.0': + resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': - resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': + resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': - resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==} + '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': + resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.130.0': - resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==} + '@oxc-parser/binding-linux-arm64-gnu@0.133.0': + resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.130.0': - resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==} + '@oxc-parser/binding-linux-arm64-musl@0.133.0': + resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': - resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': + resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': - resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==} + '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': + resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.130.0': - resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==} + '@oxc-parser/binding-linux-riscv64-musl@0.133.0': + resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.130.0': - resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==} + '@oxc-parser/binding-linux-s390x-gnu@0.133.0': + resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.130.0': - resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==} + '@oxc-parser/binding-linux-x64-gnu@0.133.0': + resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.130.0': - resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==} + '@oxc-parser/binding-linux-x64-musl@0.133.0': + resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.130.0': - resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==} + '@oxc-parser/binding-openharmony-arm64@0.133.0': + resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.130.0': - resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==} + '@oxc-parser/binding-wasm32-wasi@0.133.0': + resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.130.0': - resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==} + '@oxc-parser/binding-win32-arm64-msvc@0.133.0': + resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.130.0': - resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==} + '@oxc-parser/binding-win32-ia32-msvc@0.133.0': + resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.130.0': - resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==} + '@oxc-parser/binding-win32-x64-msvc@0.133.0': + resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.130.0': - resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': - resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.19.1': - resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} + '@oxc-resolver/binding-android-arm64@11.20.0': + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.19.1': - resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} + '@oxc-resolver/binding-darwin-arm64@11.20.0': + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.19.1': - resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} + '@oxc-resolver/binding-darwin-x64@11.20.0': + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.19.1': - resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} + '@oxc-resolver/binding-freebsd-x64@11.20.0': + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': - resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': - resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': - resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': - resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': - resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': - resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': - resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': - resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': - resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.19.1': - resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.19.1': - resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.19.1': - resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': - resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} - cpu: [ia32] - os: [win32] - - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': - resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} cpu: [x64] os: [win32] @@ -2622,8 +2617,8 @@ packages: '@simplewebauthn/browser@13.3.0': resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} - '@simplewebauthn/server@13.3.0': - resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + '@simplewebauthn/server@13.3.1': + resolution: {integrity: sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==} engines: {node: '>=20.0.0'} '@sindresorhus/merge-streams@4.0.0': @@ -2767,11 +2762,11 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} - '@tanstack/query-core@5.100.10': - resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==} + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} - '@tanstack/react-query@5.100.10': - resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==} + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: react: ^18 || ^19 @@ -2847,8 +2842,8 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} @@ -4754,8 +4749,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - knip@6.14.2: - resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==} + knip@6.16.1: + resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5197,12 +5192,12 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.130.0: - resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==} + oxc-parser@0.133.0: + resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.19.1: - resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + oxc-resolver@11.20.0: + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -5572,8 +5567,8 @@ packages: peerDependencies: react: ^19.2.5 - react-hook-form@7.76.1: - resolution: {integrity: sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==} + react-hook-form@7.77.0: + resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -7377,10 +7372,10 @@ snapshots: dependencies: hono: 4.12.23 - '@hookform/resolvers@5.4.0(react-hook-form@7.76.1(react@19.2.5))': + '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.5))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.76.1(react@19.2.5) + react-hook-form: 7.77.0(react@19.2.5) '@humanfs/core@0.19.1': {} @@ -7492,30 +7487,30 @@ snapshots: '@inquirer/ansi@2.0.7': {} - '@inquirer/confirm@6.1.1(@types/node@25.9.1)': + '@inquirer/confirm@6.1.1(@types/node@25.9.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.1) - '@inquirer/type': 4.0.7(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.2) + '@inquirer/type': 4.0.7(@types/node@25.9.2) optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 - '@inquirer/core@11.2.1(@types/node@25.9.1)': + '@inquirer/core@11.2.1(@types/node@25.9.2)': dependencies: '@inquirer/ansi': 2.0.7 '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.2) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@inquirer/figures@2.0.7': {} - '@inquirer/type@4.0.7(@types/node@25.9.1)': + '@inquirer/type@4.0.7(@types/node@25.9.2)': optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@isaacs/cliui@8.0.2': dependencies: @@ -7778,135 +7773,131 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-parser/binding-android-arm-eabi@0.130.0': + '@oxc-parser/binding-android-arm-eabi@0.133.0': optional: true - '@oxc-parser/binding-android-arm64@0.130.0': + '@oxc-parser/binding-android-arm64@0.133.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.130.0': + '@oxc-parser/binding-darwin-arm64@0.133.0': optional: true - '@oxc-parser/binding-darwin-x64@0.130.0': + '@oxc-parser/binding-darwin-x64@0.133.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.130.0': + '@oxc-parser/binding-freebsd-x64@0.133.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.130.0': + '@oxc-parser/binding-linux-arm64-gnu@0.133.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.130.0': + '@oxc-parser/binding-linux-arm64-musl@0.133.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.130.0': + '@oxc-parser/binding-linux-riscv64-musl@0.133.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.130.0': + '@oxc-parser/binding-linux-s390x-gnu@0.133.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.130.0': + '@oxc-parser/binding-linux-x64-gnu@0.133.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.130.0': + '@oxc-parser/binding-linux-x64-musl@0.133.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.130.0': + '@oxc-parser/binding-openharmony-arm64@0.133.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.130.0': + '@oxc-parser/binding-wasm32-wasi@0.133.0': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.130.0': + '@oxc-parser/binding-win32-arm64-msvc@0.133.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.130.0': + '@oxc-parser/binding-win32-ia32-msvc@0.133.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.130.0': + '@oxc-parser/binding-win32-x64-msvc@0.133.0': optional: true - '@oxc-project/types@0.130.0': {} + '@oxc-project/types@0.133.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': + '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true - '@oxc-resolver/binding-android-arm64@11.19.1': + '@oxc-resolver/binding-android-arm64@11.20.0': optional: true - '@oxc-resolver/binding-darwin-arm64@11.19.1': + '@oxc-resolver/binding-darwin-arm64@11.20.0': optional: true - '@oxc-resolver/binding-darwin-x64@11.19.1': + '@oxc-resolver/binding-darwin-x64@11.20.0': optional: true - '@oxc-resolver/binding-freebsd-x64@11.19.1': + '@oxc-resolver/binding-freebsd-x64@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.19.1': + '@oxc-resolver/binding-linux-x64-musl@11.20.0': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.19.1': + '@oxc-resolver/binding-openharmony-arm64@11.20.0': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@oxc-resolver/binding-wasm32-wasi@11.20.0': dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - optional: true - - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true '@parse/node-apn@8.1.0': @@ -8986,7 +8977,7 @@ snapshots: '@simplewebauthn/browser@13.3.0': {} - '@simplewebauthn/server@13.3.0': + '@simplewebauthn/server@13.3.1': dependencies: '@hexagon/base64': 1.1.28 '@levischuck/tiny-cbor': 0.2.11 @@ -9124,11 +9115,11 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.3.0 - '@tanstack/query-core@5.100.10': {} + '@tanstack/query-core@5.101.0': {} - '@tanstack/react-query@5.100.10(react@19.2.5)': + '@tanstack/react-query@5.101.0(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.100.10 + '@tanstack/query-core': 5.101.0 react: 19.2.5 '@testcontainers/postgresql@12.0.1': @@ -9205,13 +9196,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -9224,7 +9215,7 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@25.9.1': + '@types/node@25.9.2': dependencies: undici-types: 7.24.6 @@ -9232,7 +9223,7 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 pg-protocol: 1.13.0 pg-types: 2.2.0 @@ -9249,19 +9240,19 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -9279,7 +9270,7 @@ snapshots: '@types/web-push@3.6.4': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)': dependencies: @@ -9431,7 +9422,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -9439,7 +9430,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9455,7 +9446,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + vitest: 4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/expect@4.1.5': dependencies: @@ -9466,14 +9457,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.3) - vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + msw: 2.14.6(@types/node@25.9.2)(typescript@6.0.3) + vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -11314,25 +11305,21 @@ snapshots: kleur@4.1.5: {} - knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + knip@6.16.1: dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 - minimist: 1.2.8 - oxc-parser: 0.130.0 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-parser: 0.133.0 + oxc-resolver: 11.20.0 picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 unbash: 3.0.0 yaml: 2.9.0 zod: 4.4.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' language-subtag-registry@0.3.23: {} @@ -11524,9 +11511,9 @@ snapshots: ms@2.1.3: {} - msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3): + msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.1.1(@types/node@25.9.1) + '@inquirer/confirm': 6.1.1(@types/node@25.9.2) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -11744,56 +11731,52 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.130.0: + oxc-parser@0.133.0: dependencies: - '@oxc-project/types': 0.130.0 + '@oxc-project/types': 0.133.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.130.0 - '@oxc-parser/binding-android-arm64': 0.130.0 - '@oxc-parser/binding-darwin-arm64': 0.130.0 - '@oxc-parser/binding-darwin-x64': 0.130.0 - '@oxc-parser/binding-freebsd-x64': 0.130.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.130.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.130.0 - '@oxc-parser/binding-linux-arm64-musl': 0.130.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.130.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.130.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.130.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.130.0 - '@oxc-parser/binding-linux-x64-gnu': 0.130.0 - '@oxc-parser/binding-linux-x64-musl': 0.130.0 - '@oxc-parser/binding-openharmony-arm64': 0.130.0 - '@oxc-parser/binding-wasm32-wasi': 0.130.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.130.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.130.0 - '@oxc-parser/binding-win32-x64-msvc': 0.130.0 + '@oxc-parser/binding-android-arm-eabi': 0.133.0 + '@oxc-parser/binding-android-arm64': 0.133.0 + '@oxc-parser/binding-darwin-arm64': 0.133.0 + '@oxc-parser/binding-darwin-x64': 0.133.0 + '@oxc-parser/binding-freebsd-x64': 0.133.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.133.0 + '@oxc-parser/binding-linux-arm64-musl': 0.133.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.133.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.133.0 + '@oxc-parser/binding-linux-x64-gnu': 0.133.0 + '@oxc-parser/binding-linux-x64-musl': 0.133.0 + '@oxc-parser/binding-openharmony-arm64': 0.133.0 + '@oxc-parser/binding-wasm32-wasi': 0.133.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.133.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.133.0 + '@oxc-parser/binding-win32-x64-msvc': 0.133.0 - oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + oxc-resolver@11.20.0: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.19.1 - '@oxc-resolver/binding-android-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-x64': 11.19.1 - '@oxc-resolver/binding-freebsd-x64': 11.19.1 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 - '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-musl': 11.19.1 - '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 - '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 - '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 + '@oxc-resolver/binding-android-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-x64': 11.20.0 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 p-limit@3.1.0: dependencies: @@ -12040,7 +12023,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.1 + '@types/node': 25.9.2 long: 5.3.2 proxy-addr@2.0.7: @@ -12156,7 +12139,7 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-hook-form@7.76.1(react@19.2.5): + react-hook-form@7.77.0(react@19.2.5): dependencies: react: 19.2.5 @@ -12470,7 +12453,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.9.0(@types/node@25.9.1)(typescript@6.0.3): + shadcn@4.9.0(@types/node@25.9.2)(typescript@6.0.3): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 @@ -12491,7 +12474,7 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.3) + msw: 2.14.6(@types/node@25.9.2)(typescript@6.0.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 @@ -13107,7 +13090,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -13116,16 +13099,16 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 yaml: 2.8.4 - vitest@4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + vitest@4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -13142,10 +13125,10 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) transitivePeerDependencies: - msw From 298e4c34b409682d3778326a7ad7d65d80da214a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 12:55:56 +0200 Subject: [PATCH 02/21] perf: collapse the medication compliance hot path and the dashboard fan-out The medication list fired one compliance request per card every 30 seconds, and each request scanned the medication's full intake history and expanded the recurrence calendar five to six times. The endpoint now mints bands once per request, serves the sub-windows from that single ledger, bounds the event read to a year of selected columns and sits behind a write-invalidated 15-minute server cache; the cards poll at five minutes. The dashboard drops two redundant checklist queries by reading counts from the snapshot, the BMI chart derives from the weight series instead of refetching it, the detail page no longer serialises behind the auth probe and seeds its query from the list row. Expensive analytics reads gain a shared per-user rate limit, and the service worker stops serving cross-version shells, enables navigation preload and trims caches in one pass. --- public/sw.js | 70 ++- src/__tests__/service-worker.test.ts | 264 +++++++++++ src/app/api/analytics/range/route.ts | 9 +- .../comprehensive/__tests__/route.test.ts | 13 + src/app/api/insights/comprehensive/route.ts | 10 +- .../correlations/__tests__/route.test.ts | 29 ++ src/app/api/insights/correlations/route.ts | 10 +- .../insights/derived/__tests__/route.test.ts | 11 + src/app/api/insights/derived/route.ts | 10 +- .../[id]/compliance/__tests__/route.test.ts | 114 +++++ .../api/medications/[id]/compliance/route.ts | 409 ++++++++---------- .../medications/[id]/intake/import/route.ts | 8 + src/app/medications/[id]/page.tsx | 9 +- src/app/medications/page.tsx | 16 +- src/components/charts/health-chart.tsx | 67 ++- .../medications/glp1-medication-card.tsx | 5 +- .../medications/medication-card.tsx | 7 +- .../onboarding/getting-started-checklist.tsx | 87 ++-- .../__tests__/compliance-bundle.test.ts | 258 +++++++++++ src/lib/analytics/compliance.ts | 327 +++++++++++++- src/lib/cache/__tests__/invalidate.test.ts | 26 ++ src/lib/cache/invalidate.ts | 4 + src/lib/cache/server-cache.ts | 16 + src/lib/rate-limit.ts | 23 + 24 files changed, 1483 insertions(+), 319 deletions(-) create mode 100644 src/__tests__/service-worker.test.ts create mode 100644 src/lib/analytics/__tests__/compliance-bundle.test.ts diff --git a/public/sw.js b/public/sw.js index bbd0ae200..a726a174c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -50,16 +50,24 @@ self.addEventListener("install", (event) => { // ── Activate: clean up old caches ──────────────────────────────────────────── self.addEventListener("activate", (event) => { event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) - .map((k) => caches.delete(k)), + Promise.all([ + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) + .map((k) => caches.delete(k)), + ), ), - ) - .then(() => self.clients.claim()), + // Navigation preload lets the browser start the navigation request + // in parallel with service-worker startup; `networkFirst` consumes + // it via `event.preloadResponse`, shaving SW boot latency off every + // page navigation. Guarded — older engines lack the API. + self.registration.navigationPreload + ? self.registration.navigationPreload.enable() + : Promise.resolve(), + ]).then(() => self.clients.claim()), ); }); @@ -86,9 +94,10 @@ self.addEventListener("fetch", (event) => { return; } - // HTML pages — network-first with cache fallback + // HTML pages — network-first with cache fallback. The whole event is + // passed so `networkFirst` can consume the navigation-preload response. if (request.headers.get("accept")?.includes("text/html")) { - event.respondWith(networkFirst(request, PAGE_CACHE)); + event.respondWith(networkFirst(event, PAGE_CACHE)); return; } }); @@ -110,11 +119,21 @@ async function cacheFirst(request, cacheName) { } /** - * Network-first: try network, fall back to cache. + * Network-first: try network (preferring the navigation-preload response + * when the browser already started it), fall back to cache. + * + * The offline fallback only serves a shell cached under the CURRENT + * `CACHE_VERSION` cache names. The previous global `caches.match()` could + * resolve from a stale pre-update cache in the activation gap and serve a + * shell whose `/_next/static/*` chunk graph no longer exists + * (`ChunkLoadError` on the first lazy navigation). */ -async function networkFirst(request, cacheName) { +async function networkFirst(event, cacheName) { + const { request } = event; try { - const response = await fetch(request); + const response = + (event.preloadResponse ? await event.preloadResponse : null) || + (await fetch(request)); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); @@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) { } return response; } catch { - const cached = await caches.match(request); - if (cached) return cached; + // Version-scoped fallback: the page cache first, then the precached + // app shell — both names embed CACHE_VERSION, so a stale shell from a + // previous release can never be served here. + const pageCache = await caches.open(cacheName); + const cachedPage = await pageCache.match(request); + if (cachedPage) return cachedPage; + const staticCache = await caches.open(STATIC_CACHE); + const precached = await staticCache.match(request); + if (precached) return precached; // Offline fallback. F-7 (mobile security audit, 2026-05-16): the // previous fallback rendered hard-coded German body copy, which is @@ -143,14 +169,16 @@ async function networkFirst(request, cacheName) { } /** - * Trim cache to maxEntries by removing oldest entries. + * Trim cache to maxEntries by removing oldest entries. `keys()` returns + * insertion order, so one read + a single pass over the excess prefix + * replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads). */ async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); - let keys = await cache.keys(); - while (keys.length > maxEntries) { - await cache.delete(keys[0]); - keys = await cache.keys(); + const keys = await cache.keys(); + const excess = keys.length - maxEntries; + for (let i = 0; i < excess; i++) { + await cache.delete(keys[i]); } } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts new file mode 100644 index 000000000..66ba97460 --- /dev/null +++ b/src/__tests__/service-worker.test.ts @@ -0,0 +1,264 @@ +/** + * v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox. + * + * `public/sw.js` is plain script (no module system), so the test boots it + * inside a vm context with a fake CacheStorage and dispatches the captured + * event listeners. Three behaviours are pinned: + * + * 1. the offline HTML fallback only serves a shell cached under the + * CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would + * reference a chunk graph that no longer exists); + * 2. `activate` enables navigation preload and `networkFirst` consumes + * `event.preloadResponse` when the browser supplies it; + * 3. `trimCache` reads the key list once and deletes the excess prefix + * in a single pass (the previous loop re-fetched all keys per delete). + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import vm from "node:vm"; + +const SW_SOURCE = readFileSync( + resolve(__dirname, "../../public/sw.js"), + "utf8", +); + +const ORIGIN = "https://app.example"; +// `importScripts` throws in the sandbox, so the literal fallback version +// is active and the cache names are deterministic. +const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43"; +const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43"; + +class FakeCache { + map = new Map(); + keysCalls = 0; + + private keyOf(request: RequestInfo | URL): string { + if (typeof request === "string") return new URL(request, ORIGIN).href; + if (request instanceof URL) return request.href; + return request.url; + } + + async match(request: RequestInfo | URL): Promise { + return this.map.get(this.keyOf(request)); + } + + async put(request: RequestInfo | URL, response: Response): Promise { + this.map.set(this.keyOf(request), response); + } + + async addAll(urls: string[]): Promise { + for (const url of urls) { + this.map.set(this.keyOf(url), new Response(`precached:${url}`)); + } + } + + async keys(): Promise { + this.keysCalls += 1; + return [...this.map.keys()].map((url) => new Request(url)); + } + + async delete(request: RequestInfo | URL): Promise { + return this.map.delete(this.keyOf(request)); + } +} + +class FakeCacheStorage { + stores = new Map(); + + async open(name: string): Promise { + let store = this.stores.get(name); + if (!store) { + store = new FakeCache(); + this.stores.set(name, store); + } + return store; + } + + async keys(): Promise { + return [...this.stores.keys()]; + } + + async delete(name: string): Promise { + return this.stores.delete(name); + } + + async match(request: RequestInfo | URL): Promise { + for (const store of this.stores.values()) { + const hit = await store.match(request); + if (hit) return hit; + } + return undefined; + } +} + +interface SwHarness { + listeners: Map void>; + cacheStorage: FakeCacheStorage; + navPreload: { enabled: boolean; enable: () => Promise }; + context: Record; +} + +function bootServiceWorker(): SwHarness { + const listeners = new Map void>(); + const cacheStorage = new FakeCacheStorage(); + const navPreload = { + enabled: false, + enable: async () => { + navPreload.enabled = true; + }, + }; + const selfObj = { + addEventListener: (type: string, fn: (event: unknown) => void) => { + listeners.set(type, fn); + }, + skipWaiting: async () => {}, + clients: { claim: async () => {} }, + registration: { navigationPreload: navPreload }, + location: { origin: ORIGIN }, + }; + const context: Record = { + self: selfObj, + caches: cacheStorage, + importScripts: () => { + throw new Error("no generated version file in the sandbox"); + }, + // Default: network down. Individual tests override `context.fetch`. + fetch: async () => { + throw new TypeError("network down"); + }, + Response, + Request, + URL, + Promise, + console, + }; + vm.createContext(context); + vm.runInContext(SW_SOURCE, context); + return { listeners, cacheStorage, navPreload, context }; +} + +async function dispatchActivate(harness: SwHarness): Promise { + let settled: Promise = Promise.resolve(); + harness.listeners.get("activate")!({ + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; +} + +function dispatchNavigationFetch( + harness: SwHarness, + path: string, + preloadResponse?: Promise, +): Promise { + let captured: Promise | null = null; + harness.listeners.get("fetch")!({ + request: new Request(`${ORIGIN}${path}`, { + headers: { accept: "text/html" }, + }), + respondWith: (p: Promise) => { + captured = p; + }, + preloadResponse, + }); + if (!captured) throw new Error("fetch handler did not respond"); + return captured; +} + +describe("sw.js — activate", () => { + it("drops stale-version caches and enables navigation preload", async () => { + const harness = bootServiceWorker(); + const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1"); + await stale.put(`${ORIGIN}/`, new Response("stale shell")); + await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + + await dispatchActivate(harness); + + expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe( + false, + ); + expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true); + expect(harness.navPreload.enabled).toBe(true); + }); +}); + +describe("sw.js — networkFirst offline fallback", () => { + it("never serves a shell cached under a previous CACHE_VERSION", async () => { + const harness = bootServiceWorker(); + // A stale pre-update cache that survived into the activation gap. + const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1"); + await stale.put(`${ORIGIN}/`, new Response("stale shell")); + + const res = await dispatchNavigationFetch(harness, "/"); + + // The stale shell is ignored; the language-neutral offline page wins. + expect(res.status).toBe(503); + expect(await res.text()).toContain("Offline"); + }); + + it("serves the current-version page cache when offline", async () => { + const harness = bootServiceWorker(); + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + await pages.put(`${ORIGIN}/`, new Response("current shell")); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.text()).toBe("current shell"); + }); + + it("falls back to the current-version precached shell when the page cache misses", async () => { + const harness = bootServiceWorker(); + const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); + await statics.addAll(["/"]); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.text()).toBe("precached:/"); + }); + + it("consumes the navigation-preload response instead of re-fetching", async () => { + const harness = bootServiceWorker(); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return new Response("network shell"); + }; + + const res = await dispatchNavigationFetch( + harness, + "/", + Promise.resolve(new Response("preloaded shell")), + ); + + expect(await res.clone().text()).toBe("preloaded shell"); + expect(fetchCalls).toBe(0); + // The preloaded response was cached under the current page cache. + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/`)).toBeDefined(); + }); +}); + +describe("sw.js — trimCache", () => { + it("reads the key list once and deletes only the oldest excess entries", async () => { + const harness = bootServiceWorker(); + const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + for (let i = 0; i < 8; i++) { + await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`)); + } + store.keysCalls = 0; + + const trimCache = ( + harness.context as { trimCache?: (name: string, max: number) => Promise } + ).trimCache; + expect(typeof trimCache).toBe("function"); + await trimCache!(CURRENT_PAGE_CACHE, 5); + + // Single key read (the previous implementation re-read per deletion). + expect(store.keysCalls).toBe(1); + // Oldest three gone, newest five kept. + expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false); + expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false); + expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true); + expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true); + }); +}); diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts index bebb0a292..4788c0107 100644 --- a/src/app/api/analytics/range/route.ts +++ b/src/app/api/analytics/range/route.ts @@ -20,8 +20,9 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { ANALYTICS_RANGES, @@ -40,6 +41,12 @@ const rangeQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + const parsed = rangeQuerySchema.safeParse({ type: request.nextUrl.searchParams.get("type"), range: request.nextUrl.searchParams.get("range"), diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 637a40e5a..c9f7d5033 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -50,6 +50,12 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -84,6 +90,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -104,6 +111,12 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); __resetAllCachesForTests(); + // v1.15.20 — default to an allowing analytics-read budget. + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); // Default to assistant-on so the gate doesn't 403 every test. ( prisma.appSettings.findUnique as ReturnType diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 9a9042b28..9b36a8f23 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { resolveProvider } from "@/lib/ai/provider"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; import { summarize } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -23,6 +23,7 @@ import { getMedicationCategories } from "@/lib/medication-category"; import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { @@ -34,6 +35,13 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + // v1.4.31 — comprehensive feeds the hero strip narration and the // recommendations grid that share the Coach gate. await requireAssistantSurface("coach"); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index ae31fdea0..9933d4ec1 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -17,6 +17,12 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -35,6 +41,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -43,6 +50,12 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // v1.15.20 — default to an allowing analytics-read budget. + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); // v1.4.31 — default to an all-on flag set so existing assertions // ride through unchanged. Tests that need the gate-off path set // their own findUnique resolution. @@ -118,4 +131,20 @@ describe("GET /api/insights/correlations", () => { const res = await callGet(makeReq()); expect(res.status).toBe(403); }); + + it("returns 429 when the shared analytics-read budget is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(429); + expect(vi.mocked(checkAnalyticsReadRateLimit)).toHaveBeenCalledWith( + "user-1", + ); + // The limited request never reaches the series reads. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index d628e588f..eae72346b 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -16,8 +16,9 @@ * fetches + day-keys + responds. No LLM, no narrative, no cache table. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { getLocalDateParts } from "@/lib/timezone"; @@ -65,6 +66,13 @@ function toDailyMeans( export const GET = apiHandler(async () => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index 7711d0fcf..fca913f30 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -17,6 +17,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. This file +// uses `clearAllMocks` (implementations survive), so the factory default +// is enough. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: 0, + }), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 0cc883acf..8c0f9d0e8 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -20,9 +20,10 @@ */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { @@ -50,6 +51,13 @@ const derivedQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + await requireAssistantSurface("insightStatus"); const parsed = derivedQuerySchema.safeParse({ diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 4541af588..142293b92 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -33,6 +33,26 @@ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), })); +// v1.15.20 — the route reads through the per-user compliance cache and a +// per-user rate limit. Pass-through mocks keep each test's fixture +// isolated (the real module-scope cache would leak state across tests); +// the rate-limit tests below flip `allowed` explicitly. +vi.mock("@/lib/cache/server-cache", () => ({ + cached: vi.fn( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ), + caches: { medicationCompliance: {} }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -51,6 +71,8 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { cached } from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; import { getUserTodayBounds } from "@/lib/timezone"; import { userDayKey } from "@/lib/tz/format"; @@ -106,6 +128,17 @@ function medication(schedules: unknown[]) { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // `resetAllMocks` clears the factory implementations — re-prime the + // pass-through cache and the allowing rate limit for every test. + vi.mocked(cached).mockImplementation( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); }); async function callRoute(): Promise> { @@ -353,3 +386,84 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { expect(display.longDays).toBeGreaterThan(30); }); }); + +// v1.15.20 — server cache + bounded event read + per-user rate limit. +describe("GET /api/medications/[id]/compliance — caching and limits", () => { + function mockDailyMed() { + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + medication([ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ]) as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + } + + it("returns 429 when the per-user rate limit is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(429); + expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith( + "medication-compliance:user-1", + 60, + 60_000, + ); + // The limited request never touches the medication tables. + expect(prisma.medication.findUnique).not.toHaveBeenCalled(); + }); + + it("reads through the per-user compliance cache key", async () => { + mockDailyMed(); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + + expect(vi.mocked(cached)).toHaveBeenCalledTimes(1); + const [, key] = vi.mocked(cached).mock.calls[0]; + // `${userId}|...` prefix is what `invalidateUserMedications` sweeps. + expect(key).toBe("user-1|med-1|compliance"); + }); + + it("bounds the intake-event read to a trailing window and the consumed columns", async () => { + mockDailyMed(); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + + const call = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0] as { + where: { scheduledFor?: { gte?: Date } }; + select?: Record; + }; + // Lower-bounded: the widest served window is 365 days (+1 day margin). + const gte = call.where.scheduledFor?.gte; + expect(gte).toBeInstanceOf(Date); + const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000; + expect((gte as Date).getTime()).toBeGreaterThan(minExpected); + // Column-bounded: only the four fields the math consumes. + expect(call.select).toEqual({ + takenAt: true, + skipped: true, + scheduledFor: true, + autoMissed: true, + }); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 7f75efd1a..76c1face0 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -3,81 +3,37 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { - buildComplianceDisplay, buildComplianceMedicationContext, - calculateCompliance, + buildMedicationComplianceBundle, lastNonSkippedTakenAt, + type ComplianceDisplay, + type ComplianceResult, + type DailyComplianceEntry, } from "@/lib/analytics/compliance"; -import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; -import type { SlotBand } from "@/lib/medications/scheduling/attribution"; -import { - buildBandsForSchedules, - type BandMinterMedication, -} from "@/lib/medications/scheduling/band-minter"; -import { - reconstructDoseHistory, - type DoseHistoryRow, - type HistoryIntake, -} from "@/lib/medications/scheduling/dose-history"; -import { - type CanonicalSchedule, - type RecurrenceContext, -} from "@/lib/medications/scheduling/recurrence"; -import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -type MedicationScheduleRow = { - id: string; - rrule: string | null; - rollingIntervalDays: number | null; - timesOfDay: string[]; - daysOfWeek: string | null; - windowStart: string; - windowEnd: string; - reminderGraceMinutes: number | null; - scheduleType: "SCHEDULED" | "PRN" | "CYCLIC"; - cyclicOnWeeks: number | null; - cyclicOffWeeks: number | null; - /** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */ - doseWindows: unknown; -}; +const DAY_MS = 24 * 60 * 60 * 1000; /** - * v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the - * band minter consumes. A legacy daily row carrying only `windowStart` - * surfaces it as the single `timeOfDay` so the minter mints its daily band. + * Lower bound for the intake-event read. The widest window any served + * block needs is the 365-day display rung; one extra day absorbs the + * inclusive-boundary edge so the ledger's own 365-day clamp never sits + * on a half-fetched day. */ -function toCanonicalForBands( - s: MedicationScheduleRow, - oneShot: boolean, -): CanonicalSchedule { - const base: CanonicalSchedule = { - id: s.id, - rrule: s.rrule, - rollingIntervalDays: s.rollingIntervalDays, - timesOfDay: s.timesOfDay, - daysOfWeek: s.daysOfWeek, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - reminderGraceMinutes: s.reminderGraceMinutes, - scheduleType: s.scheduleType, - cyclicOnWeeks: s.cyclicOnWeeks, - cyclicOffWeeks: s.cyclicOffWeeks, - doseWindows: normaliseDoseWindows(s.doseWindows), - }; - if ( - base.timesOfDay.length === 0 && - base.rrule === null && - base.rollingIntervalDays === null && - base.scheduleType !== "PRN" && - !oneShot - ) { - return { ...base, timesOfDay: [base.windowStart] }; - } - return base; +const EVENT_FETCH_WINDOW_DAYS = 366; + +/** The cached response body (the exact public wire shape). */ +interface CompliancePayload { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + dailyCompliance: Record; + complianceDisplay: ComplianceDisplay; } /** @@ -134,11 +90,153 @@ function bucketLedgerRow( } } +/** + * The full compliance computation for one medication. Lifted out of the + * handler so the server cache can wrap it: ONE bounded intake-event read + * (only the four columns the math consumes, floored at the 366-day fetch + * window) feeds ONE shared band-expansion pass + * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks, + * the cadence-scaled display rows AND the 90-day heatmap — replacing the + * historical five-plus per-request expansions over an unbounded, + * all-columns event read. + */ +async function buildCompliancePayload( + medication: { + id: string; + createdAt: Date; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + schedules: Parameters[1]; + }, + userId: string, + userTz: string, +): Promise { + // v1.15.9 — pin a single `now` and thread it into every cadence + // computation so no block can straddle a day boundary on a slow request. + const now = new Date(); + const createdAt = medication.createdAt; + + const fetchFrom = new Date( + Math.max( + createdAt.getTime(), + now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, + ), + ); + const events = await prisma.medicationIntakeEvent.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the compliance read. + // Bounded to the 366-day fetch window: every served block is a suffix + // of the 365-day ledger window, so older rows can never change the + // response. + where: { + medicationId: medication.id, + userId, + deletedAt: null, + scheduledFor: { gte: fetchFrom }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + takenAt: true, + skipped: true, + scheduledFor: true, + // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a + // miss, not a neutral skip. + autoMissed: true, + }, + }); + + // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator + // routes through the canonical engine (RRULE / rolling / one-shot / PRN / + // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the + // fetch window (rolling cadences re-anchor on it). + const lastIntakeAt = lastNonSkippedTakenAt(events); + const medicationContext = buildComplianceMedicationContext( + medication, + lastIntakeAt, + userTz, + ); + + // ONE shared expansion pass for every served block (the v1.15.20 + // performance fix): bands + ledger + timeline are minted once over the + // widest window and each sub-window is a tally over the same rows. + const bundle = buildMedicationComplianceBundle( + events, + medication.schedules, + medicationContext, + now, + ); + + // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE + // unified dose-history ledger (the same bands the % + the history view + // read), so the per-day timing split (on-time / late) and the per-day + // missed marks can never disagree with the headline rate. The 90-day + // window is carved out of the shared ledger by `row.at`; the window + // floor clamps to the medication's creation so pre-existence days never + // mint phantom slots. + const heatmapFrom = new Date( + Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), + ); + + const dailyCompliance: Record = {}; + const byDay = new Map(); + const ensureDay = (key: string): DailyComplianceEntry => { + let entry = byDay.get(key); + if (!entry) { + entry = { + expected: 0, + expectedCount: 0, + due: false, + taken: 0, + skipped: 0, + onTime: 0, + late: 0, + veryLate: 0, + early: 0, + }; + byDay.set(key, entry); + } + return entry; + }; + + // Bucket each ledger row into its user-tz day. A slot row buckets on its + // anchor; an ad-hoc row on its real take time. + for (const row of bundle.ledgerRows) { + if (row.at.getTime() < heatmapFrom.getTime()) continue; + const key = userDayKey(row.at, userTz); + const entry = ensureDay(key); + bucketLedgerRow(entry, row); + } + + for (const [key, entry] of byDay) { + entry.due = entry.expectedCount > 0; + dailyCompliance[key] = entry; + } + + return { + compliance7: bundle.compliance7, + compliance30: bundle.compliance30, + dailyCompliance, + complianceDisplay: bundle.complianceDisplay, + }; +} + export const GET = apiHandler( async (_request: Request, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; + + // The medications list fans this endpoint out once per card, so the + // per-user budget is generous — it only caps a runaway client loop. + const rl = await checkRateLimit( + `medication-compliance:${user.id}`, + 60, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many compliance requests. Please retry later.", 429); + } + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; @@ -152,167 +250,19 @@ export const GET = apiHandler( return apiError("Medication not found", 404); } - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance read. - where: { medicationId: id, userId: user.id, deletedAt: null }, - orderBy: { scheduledFor: "desc" }, - }); - - const mapped = events.map((e) => ({ - takenAt: e.takenAt, - skipped: e.skipped, - scheduledFor: e.scheduledFor, - // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a - // miss, not a neutral skip. - autoMissed: e.autoMissed, - })); - - const createdAt = medication.createdAt; - - // v1.7.0 SB-SCHED-2 — thread the medication context so the - // denominator routes through the canonical engine (RRULE / rolling / - // one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker. - // `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences - // re-anchor on it); the events list is already ordered scheduledFor - // desc, so scan for the max takenAt. - const lastIntakeAt = lastNonSkippedTakenAt(mapped); const userTz = user.timezone || "Europe/Berlin"; - const medicationContext = buildComplianceMedicationContext( - medication, - lastIntakeAt, - userTz, + + // Read-through the per-user compliance cache (15 min TTL). Every + // intake / medication write flushes the `${userId}|` prefix via + // `invalidateUserMedications`, so a warm entry can only be stale + // relative to wall-clock drift, never to a user action. + const payload = await cached( + caches.medicationCompliance as ServerCache, + `${user.id}|${id}|compliance`, + () => buildCompliancePayload(medication, user.id, userTz), + annotate, ); - // v1.15.9 — pin a single `now` and thread it into every cadence - // computation. The two `calculateCompliance` calls + the display windows - // can otherwise straddle a day boundary on a slow request, so a dose - // would count in one window and not the next within the same response. - const now = new Date(); - - const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, { - now, - medicationContext, - }); - const compliance30 = calculateCompliance( - mapped, - medication.schedules, - 30, - createdAt, - { now, medicationContext }, - ); - - // v1.8.6 — the two-row compliance display. The card always shows two - // percentage rows; the server scales the two windows to the dosing - // cadence (dense meds keep 7 / 30 days, sparse meds step both windows - // up) and computes each row's rate over the chosen span. `compliance7` - // / `compliance30` above are untouched — iOS + the Health Score read - // them verbatim. - const complianceDisplay = buildComplianceDisplay( - mapped, - medication.schedules, - medicationContext, - { now }, - ); - - // v1.15.18 — the heatmap / line-chart daily map is now bucketed from the - // ONE unified dose-history ledger (the same bands the % + the history view - // read), so the per-day timing split (on-time / late) and the per-day - // missed marks can never disagree with the headline rate. The legacy - // per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount - // ForDay` walk are retired here in favour of band membership: a take inside - // a slot's on-time band reads on-time, inside its late tail reads late, - // outside every band reads ad-hoc; an unfilled slot past its miss cutoff - // reads missed; a future slot is upcoming (not yet due). - const dailyCompliance: Record = {}; - - // Build the bands across the whole 90-day heatmap window once. The window - // floor clamps to the medication's creation so pre-existence days never - // mint phantom slots. - const DAY_MS = 24 * 60 * 60 * 1000; - const windowFrom = new Date( - Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), - ); - const bandMedication: BandMinterMedication = { - id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, - oneShot: medication.oneShot, - createdAt: medication.createdAt, - }; - const bandCtx: RecurrenceContext = { - medication: { - id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, - oneShot: medication.oneShot, - createdAt: medication.createdAt, - }, - timeZone: userTz, - lastIntakeAt, - }; - const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map( - (s) => toCanonicalForBands(s, medication.oneShot), - ); - const intakeInstants = mapped - .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now) - .map((e) => e.takenAt as Date) - .sort((a, b) => a.getTime() - b.getTime()); - const groups = buildBandsForSchedules({ - medication: bandMedication, - schedules: canonicalSchedules, - ctx: bandCtx, - userTz, - range: { from: windowFrom, to: now }, - now, - intakeInstants, - }); - const bands: SlotBand[] = []; - for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); - } - const historyIntakes: HistoryIntake[] = mapped - .filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed, - })); - const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now); - - // Bucket each ledger row into its user-tz day. A slot row buckets on its - // anchor; an ad-hoc row on its real take time. - const byDay = new Map(); - const ensureDay = (key: string): DailyComplianceEntry => { - let entry = byDay.get(key); - if (!entry) { - entry = { - expected: 0, - expectedCount: 0, - due: false, - taken: 0, - skipped: 0, - onTime: 0, - late: 0, - veryLate: 0, - early: 0, - }; - byDay.set(key, entry); - } - return entry; - }; - - for (const row of ledgerRows) { - const key = userDayKey(row.at, userTz); - const entry = ensureDay(key); - bucketLedgerRow(entry, row); - } - - for (const [key, entry] of byDay) { - entry.due = entry.expectedCount > 0; - dailyCompliance[key] = entry; - } - annotate({ action: { name: "medication.compliance", @@ -320,18 +270,13 @@ export const GET = apiHandler( entity_id: id, }, meta: { - compliance7: compliance7.rate, - compliance30: compliance30.rate, - complianceShortDays: complianceDisplay.shortDays, - complianceLongDays: complianceDisplay.longDays, + compliance7: payload.compliance7.rate, + compliance30: payload.compliance30.rate, + complianceShortDays: payload.complianceDisplay.shortDays, + complianceLongDays: payload.complianceDisplay.longDays, }, }); - return apiSuccess({ - compliance7, - compliance30, - dailyCompliance, - complianceDisplay, - }); + return apiSuccess(payload); }, ); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 0cff38b3f..9765c07ac 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForDay, dayKeyForScheduledFor, @@ -158,6 +159,13 @@ export const POST = apiHandler( } } + // v1.15.20 — imported intakes change every cached medication read + // (list, intake caches, compliance payload). This write path was the + // one intake surface missing the flush. + if (imported > 0) { + invalidateUserMedications(user.id); + } + await auditLog("medication.intake.import", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 330c672bd..df600ff4c 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -56,7 +56,12 @@ export default function MedicationDetailPage({ if (!res.ok) throw new Error("medication_detail_failed"); return (await res.json()).data as MedicationDetailSnapshot; }, - enabled: isAuthenticated, + // v1.15.20 — deliberately NOT gated on `isAuthenticated`: the session + // cookie rides the fetch either way, so waiting for `/api/auth/me` + // serialised two round-trips into a waterfall on every detail + // navigation. An unauthenticated visit fails fast with a 401 here and + // the redirect effect above still routes to the login page (the + // render guard below keeps the spinner up until it does). // A deleted medication 404s here. Don't burn a retry/backoff cycle on // a resource that no longer exists — the delete handler evicts this // key on success, but `retry: false` hardens any caller that @@ -64,7 +69,7 @@ export default function MedicationDetailPage({ retry: false, }); - if (authLoading || isLoading) { + if (authLoading || isLoading || !isAuthenticated) { return (
{ + if (valueMode !== "bmi") return points; + if (!bmiDivisor) return []; + return points.map((p) => { + const out: ChartDataPoint = { + date: p.date, + timestamp: p.timestamp, + }; + for (const type of types) { + const v = p[type]; + if (typeof v === "number") { + out[type] = v / bmiDivisor; + } + } + return out; + }); + }, + [valueMode, bmiDivisor, types], + ), enabled: isAuthenticated, }); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 4fe02593f..a9fdc9433 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -203,7 +203,10 @@ export function Glp1MedicationCard({ const json = await res.json(); return json.data as ComplianceData; }, - staleTime: 30 * 1000, + // v1.15.20 — same 5-minute window as the generic card: dose actions + // invalidate the key explicitly, so a short staleTime only re-fired + // a request per card on every list visit. + staleTime: 5 * 60 * 1000, }); // v1.4.37 W4b — same reminder-thresholds source as the generic diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7342cb11d..9ee34e52b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -146,7 +146,12 @@ export function MedicationCard({ const json = await res.json(); return json.data as ComplianceData; }, - staleTime: 30 * 1000, + // v1.15.20 — the card renders two percentage rows that move on a + // dose action (which invalidates this key through + // `medicationDependentKeys`) or over hours of wall-clock drift; a + // 30 s window only re-fired one request per card on every list + // visit. Five minutes matches the reminder-thresholds query below. + staleTime: 5 * 60 * 1000, }); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index d911b1d0b..cdbdf9da1 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -14,14 +14,12 @@ import { X, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as -// `ChecklistAnalyticsData`; aliased back to the local name. -import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { queryKeys } from "@/lib/query-keys"; import { buildChecklist, checklistProgress, @@ -181,40 +179,54 @@ export function GettingStartedChecklist() { } }, [expanded]); - // Light-weight queries: each fetch is small and cached by tanstack. - // We rely on the React Query cache the dashboard already uses for - // analytics, so this won't fire a second request when the dashboard - // and the checklist mount together. v1.4.33 IW2 routes the checklist - // onto IW1's slim `?slice=summaries` branch — the only field - // consumed is `summaries[METRIC].count`, which the slim slice fills - // directly. The cache slot is distinct from the dashboard's thick - // slice but each consumer's payload is byte-correct for its own - // reads, and the shared hook keeps the legacy `?.data` unwrap so the - // canonical consumers still see `{summaries, …}` not `{data: …}`. - const analyticsQuery = useAnalyticsQuery({ - slice: "summaries", - enabled: !!user, - }); - const analyticsData = analyticsQuery.data as AnalyticsData | undefined; + // v1.15.20 — the measurement count is derived from the dashboard + // snapshot the page already fetches (`tiles.summaries` is the same + // `computeSummariesSlice` payload the legacy `?slice=summaries` + // analytics query carried), so the checklist no longer fires its own + // analytics request on every dashboard load. Subscribing to the shared + // `queryKeys.dashboardSnapshot()` cell costs zero extra network. + const snapshotQuery = useDashboardSnapshot(!!user); + const snapshotData = snapshotQuery.data; + + const measurementCount = useMemo(() => { + const summaries = snapshotData?.tiles?.summaries ?? {}; + let total = 0; + for (const key of Object.keys(summaries)) { + total += summaries[key]?.count ?? 0; + } + return total; + }, [snapshotData]); + + // v1.15.20 — only fetch the checklist's supporting data while the card + // can actually render: the user is still in setup (`shouldShowChecklist`'s + // `onboardingCompletedAt == null || measurementCount < 5` rule) and has + // not dismissed it. Established users used to pay this medications fetch + // (and, pre-v1.5, two more) on every dashboard load for a card that + // never mounts. Gated on the snapshot having resolved so the count is + // real, not the loading-default 0. + const checklistRelevant = + !!user && + !dismissedAll && + snapshotData !== undefined && + (user.onboardingCompletedAt == null || measurementCount < 5); const { data: medsData } = useQuery>({ - queryKey: ["medications"], + queryKey: queryKeys.medications(), queryFn: async () => { const res = await fetch("/api/medications"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data; }, - enabled: !!user, + enabled: checklistRelevant, }); // v1.5 perf audit: skip these two fetches once the user is past // onboarding. The checklist hides itself anyway via shouldShowChecklist - // when onboardingCompletedAt != null AND measurementCount >= 5; the - // analytics + medications queries above piggy-back on the dashboard's - // shared cache, but withings/status and notifications/preferences are - // unique to this component and were burning ~950 ms of network on every - // dashboard load for established users. See docs/audit/v15-performance.md. + // when onboardingCompletedAt != null AND measurementCount >= 5; + // withings/status and notifications/preferences are unique to this + // component and were burning ~950 ms of network on every dashboard load + // for established users. See docs/audit/v15-performance.md. const onboardingPending = !!user && user.onboardingCompletedAt == null; const { data: withingsData } = useQuery({ @@ -239,15 +251,6 @@ export function GettingStartedChecklist() { enabled: onboardingPending, }); - const measurementCount = useMemo(() => { - const summaries = analyticsData?.summaries ?? {}; - let total = 0; - for (const key of Object.keys(summaries)) { - total += summaries[key]?.count ?? 0; - } - return total; - }, [analyticsData]); - const medicationCount = medsData?.length ?? 0; const withingsConnected = withingsData?.connected === true; const notificationsConfigured = (notificationsData?.channels ?? []).some( @@ -291,19 +294,19 @@ export function GettingStartedChecklist() { }); // v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user - // AND the analytics query have resolved we render NOTHING. The + // AND the snapshot query have resolved we render NOTHING. The // previous version rendered a default-true `show` branch while - // `measurementCount` was still 0 (because analytics hadn't returned), + // `measurementCount` was still 0 (because the data hadn't returned), // so a user whose actual `measurementCount >= 5` saw the card flash // for ~500 ms before `shouldShowChecklist` flipped to false. We rely - // on `analyticsQuery.data === undefined` as the loading sentinel — + // on `snapshotData === undefined` as the loading sentinel — // tanstack-query writes `data` exactly once per fetch, so this is // race-free across hot-reloads and stale-cache invalidation. (The - // dashboard's `/api/analytics` query shares the cache key, so the - // checklist almost always sees the cached value synchronously on - // mount and never blocks UI.) + // dashboard's snapshot query shares the cache key, so the checklist + // almost always sees the cached value synchronously on mount and + // never blocks UI.) if (!user) return null; - if (analyticsQuery.data === undefined) return null; + if (snapshotData === undefined) return null; if (!show) return null; return ( diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts new file mode 100644 index 000000000..519975a8f --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -0,0 +1,258 @@ +/** + * v1.15.20 — single-pass compliance bundle parity. + * + * `buildMedicationComplianceBundle` replaces the per-medication compliance + * route's five-plus expansion passes (calculateCompliance × 4, the window + * ladder probes, the heatmap mint) with ONE band expansion + ONE cadence + * timeline. These tests pin the bundle's blocks against the historical + * per-window composition so the refactor cannot drift the public payload: + * every block must be deep-equal to what `calculateCompliance` / + * `buildComplianceDisplay` produce for the same fixture. + */ +import { describe, it, expect } from "vitest"; + +import { + buildComplianceMedicationContext, + buildComplianceLedgerRows, + buildMedicationComplianceBundle, + buildComplianceDisplay, + calculateCompliance, + lastNonSkippedTakenAt, + tallyComplianceFromLedger, + tallyLedgerRows, + type ComplianceSchedule, +} from "../compliance"; + +const TZ = "UTC"; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Deterministic anchor — keeps every window fully in the fixture's hands. */ +const NOW = new Date("2026-06-01T12:00:00Z"); + +function dailySchedule(): ComplianceSchedule { + return { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +function rollingSchedule(intervalDays: number): ComplianceSchedule { + return { + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: intervalDays, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +/** A slot instant `daysAgo` before NOW at the given "HH:mm" (UTC fixture). */ +function slotAt(daysAgo: number, hhmm: string): Date { + const day = new Date(NOW.getTime() - daysAgo * DAY_MS); + const [h, m] = hhmm.split(":").map(Number); + const d = new Date(day); + d.setUTCHours(h, m, 0, 0); + return d; +} + +interface FixtureEvent { + takenAt: Date | null; + skipped: boolean; + scheduledFor: Date; + autoMissed?: boolean; +} + +function dailyFixtureEvents(): FixtureEvent[] { + return [ + // Taken on time across several days. + { takenAt: slotAt(1, "08:05"), skipped: false, scheduledFor: slotAt(1, "08:00") }, + { takenAt: slotAt(2, "08:10"), skipped: false, scheduledFor: slotAt(2, "08:00") }, + { takenAt: slotAt(4, "07:55"), skipped: false, scheduledFor: slotAt(4, "08:00") }, + // A deliberate skip (excluded from the denominator). + { takenAt: null, skipped: true, scheduledFor: slotAt(3, "08:00") }, + // A taken-late dose (inside the late tail). + { takenAt: slotAt(5, "10:30"), skipped: false, scheduledFor: slotAt(5, "08:00") }, + // Day 6 has no event → the unfilled slot reads missed. + // An auto-missed forgotten dose. + { + takenAt: null, + skipped: false, + scheduledFor: slotAt(8, "08:00"), + autoMissed: true, + }, + ]; +} + +describe("buildMedicationComplianceBundle — parity with the per-window composition", () => { + it("daily medication: every block equals the legacy multi-pass output", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance(events, schedules, 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.compliance30).toEqual( + calculateCompliance(events, schedules, 30, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay(events, schedules, ctx, { now: NOW }), + ); + + // The ledger window clamps to the medication's creation. + expect(bundle.ledgerFrom).toEqual(createdAt); + // One band per daily slot over the 40-day life → 40 or 41 slot rows + // (the boundary day depends on the creation instant vs slot time). + const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot"); + expect(slotRows.length).toBeGreaterThanOrEqual(39); + expect(slotRows.length).toBeLessThanOrEqual(41); + }); + + it("rolling 35-day medication: display windows + rates match the legacy path", () => { + const createdAt = new Date(NOW.getTime() - 400 * DAY_MS); + const schedules = [rollingSchedule(35)]; + const events: FixtureEvent[] = [ + { + takenAt: new Date(NOW.getTime() - 25 * DAY_MS), + skipped: false, + scheduledFor: new Date(NOW.getTime() - 25 * DAY_MS), + }, + { + takenAt: new Date(NOW.getTime() - 60 * DAY_MS), + skipped: false, + scheduledFor: new Date(NOW.getTime() - 60 * DAY_MS), + }, + ]; + const ctx = buildComplianceMedicationContext( + { + startsOn: new Date(NOW.getTime() - 380 * DAY_MS), + endsOn: null, + oneShot: false, + createdAt, + }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance(events, schedules, 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.compliance30).toEqual( + calculateCompliance(events, schedules, 30, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay(events, schedules, ctx, { now: NOW }), + ); + // A 35-day cadence cannot clear the stability floor on [7, 30]. + expect(bundle.complianceDisplay.shortDays).toBeGreaterThan(7); + expect(bundle.complianceDisplay.longDays).toBeGreaterThan(30); + }); + + it("empty schedule list: zeros short-circuit matches calculateCompliance", () => { + const createdAt = new Date(NOW.getTime() - 10 * DAY_MS); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + const bundle = buildMedicationComplianceBundle([], [], ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance([], [], 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay([], [], ctx, { now: NOW }), + ); + expect(bundle.ledgerRows).toEqual([]); + }); +}); + +describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { + it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + const from = new Date(NOW.getTime() - 30 * DAY_MS); + + const rows = buildComplianceLedgerRows(events, schedules, ctx, from, NOW, NOW); + expect(tallyLedgerRows(rows)).toEqual( + tallyComplianceFromLedger(events, schedules, ctx, from, NOW, NOW), + ); + }); + + it("a sub-window tally counts only rows inside the window", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + createdAt, + NOW, + NOW, + ); + const wide = tallyLedgerRows(rows); + // 3.5 days reaches back past the day −3 08:00 slot (NOW is 12:00Z, so a + // plain 3-day bound would cut that morning slot off). + const narrow = tallyLedgerRows(rows, { + from: new Date(NOW.getTime() - 3.5 * DAY_MS), + to: NOW, + }); + + // Days −1 and −2 are taken on time; day −3 is a skip. Everything + // older falls outside the window. + expect(narrow.takenOnTime).toBe(2); + expect(narrow.skipped).toBe(1); + expect(narrow.denominator).toBeLessThan(wide.denominator); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index e1acb9718..bb144c361 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -36,6 +36,7 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { reconstructDoseHistory, + type DoseHistoryRow, type HistoryIntake, } from "@/lib/medications/scheduling/dose-history"; import { @@ -1155,6 +1156,39 @@ export function tallyComplianceFromLedger( now: Date, windowConfig?: DoseWindowConfig, ): LedgerComplianceTally { + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + from, + to, + now, + windowConfig, + ); + return tallyLedgerRows(rows); +} + +/** + * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE + * unified dose-history ledger for them. This is the expansion pass behind + * {@link tallyComplianceFromLedger}, extracted so a caller that needs + * several trailing sub-windows (7-day / 30-day / display / heatmap, all + * ending at `now`) can mint the bands ONCE over the widest window and tally + * each sub-window from the same rows via {@link tallyLedgerRows} instead of + * re-expanding per window. + * + * Pure / synchronous: bands come from pre-fetched schedules + intake + * instants; no DB access. + */ +export function buildComplianceLedgerRows( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): DoseHistoryRow[] { const medication: BandMinterMedication = { id: "compliance-tally", startsOn: ctx.startsOn, @@ -1222,14 +1256,31 @@ export function tallyComplianceFromLedger( autoMissed: e.autoMissed ?? false, })); - const rows = reconstructDoseHistory(bands, intakes, now); + return reconstructDoseHistory(bands, intakes, now); +} +/** + * Tally pre-built ledger rows into the {@link LedgerComplianceTally} + * counters. When `window` is supplied only rows whose instant (`row.at`) + * falls inside `[window.from, window.to]` (inclusive) are counted — that is + * how a sub-window tally is carved out of a wider single-pass ledger. The + * window-less call tallies every row, byte-identical to the historical + * {@link tallyComplianceFromLedger} behaviour. + */ +export function tallyLedgerRows( + rows: DoseHistoryRow[], + window?: { from: Date; to: Date }, +): LedgerComplianceTally { let takenOnTime = 0; let takenLate = 0; let missed = 0; let skipped = 0; let adHoc = 0; for (const row of rows) { + if (window) { + const t = row.at.getTime(); + if (t < window.from.getTime() || t > window.to.getTime()) continue; + } switch (row.status) { case "taken_on_time": takenOnTime++; @@ -1517,3 +1568,277 @@ export function calculateCompliance( return { totalExpected, taken, skipped, missed, rate, streak }; } + +/** + * Widest trailing window the single-pass compliance ledger has to cover: + * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every + * sub-window the per-medication compliance endpoint serves (7 / 30 / + * cadence-scaled display rows / 90-day heatmap) is a suffix of it. + */ +export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; + +/** + * The per-medication compliance payload computed from ONE shared expansion + * pass. `ledgerRows` is the unified dose-history ledger over + * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by + * filtering on `row.at`. + */ +export interface MedicationComplianceBundle { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + complianceDisplay: ComplianceDisplay; + /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ + ledgerRows: DoseHistoryRow[]; + /** Lower bound of the mint window (clamped to the medication's creation). */ + ledgerFrom: Date; +} + +/** + * Build every block of the per-medication compliance response from a single + * band-expansion pass. + * + * The historical composition called {@link calculateCompliance} four times + * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more + * occurrence expansions for the rung probes) and a separate 90-day heatmap + * mint — five-plus full band expansions per request. This builder instead: + * + * 1. mints the bands + reconstructs the ledger ONCE over + * `[max(createdAt, now − 365 d), now]` (every served window is a + * suffix of that range and ends at `now`, so a sub-window tally is a + * filter over `row.at`, not a re-expansion); + * 2. builds ONE cadence timeline over the same range for the per-window + * streaks ({@link streaksFromTimeline} bounds its day-walk by the + * requested window, so the wider timeline serves every window); + * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row + * counts (one band per engine occurrence, so the counts match the + * historical `expectedSlotsBetween` probes). + * + * The returned blocks carry the exact public shapes the route has always + * served — only the number of expansion passes changed. + */ +export function buildMedicationComplianceBundle( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, +): MedicationComplianceBundle { + const ledgerPeriodStart = new Date( + now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, + ); + const ledgerFrom = + ctx.createdAt.getTime() > ledgerPeriodStart.getTime() + ? ctx.createdAt + : ledgerPeriodStart; + + const hasSchedules = schedules.length > 0; + const ledgerRows = hasSchedules + ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) + : []; + + // ONE cadence timeline over the full ledger window. Each per-window + // streak below walks only its own trailing `effectiveDays`, so sharing + // the wide timeline reproduces the per-window builds. + const fullDays = Math.max( + 1, + Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), + ); + const timeline = hasSchedules + ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) + : []; + + const resultForWindow = (days: number): ComplianceResult => { + if (!hasSchedules) { + // Mirrors `calculateCompliance`'s empty-schedule short-circuit. + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); + const effectiveStart = + ctx.createdAt.getTime() > periodStart.getTime() + ? ctx.createdAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), + ); + const tally = tallyLedgerRows(ledgerRows, { from: effectiveStart, to: now }); + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + ctx.timeZone, + ); + return { + totalExpected: tally.taken + tally.skipped + tally.missed, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + streak, + }; + }; + + // Window-ladder selection from the ledger's slot rows. A slot row is one + // minted band, and the minter emits one band per engine occurrence, so + // counting slot rows over a trailing window equals the historical + // `expectedSlotsBetween(...).length` probe for that window. + const expectedCache = new Map(); + const expectedOver = (days: number): number => { + const hit = expectedCache.get(days); + if (hit !== undefined) return hit; + const from = Math.max( + ctx.createdAt.getTime(), + now.getTime() - days * ONE_DAY_MS, + ); + let count = 0; + for (const row of ledgerRows) { + if (row.kind !== "slot") continue; + const t = row.at.getTime(); + if (t >= from && t <= now.getTime()) count++; + } + expectedCache.set(days, count); + return count; + }; + + let selection: ComplianceWindowSelection | null = null; + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expectedOver(shortDays); + const expectedLong = expectedOver(longDays); + if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { + selection = { shortDays, longDays, expectedShort, expectedLong }; + break; + } + } + if (!selection) { + // No rung cleared the floor — fall back to the widest rung, exactly + // like `selectComplianceWindows`. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + selection = { + shortDays, + longDays, + expectedShort: expectedOver(shortDays), + expectedLong: expectedOver(longDays), + }; + } + + const compliance7 = resultForWindow(7); + const compliance30 = resultForWindow(30); + const short = resultForWindow(selection.shortDays); + const long = resultForWindow(selection.longDays); + + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + selection.expectedShort, + events, + ); + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + const complianceDisplay: ComplianceDisplay = { + shortDays: selection.shortDays, + longDays: selection.longDays, + expectedShort: selection.expectedShort, + expectedLong: selection.expectedLong, + minStableDoses: MIN_STABLE_DOSES, + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; + + return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom }; +} + +/** + * The cadence-timeline construction from {@link calculateCompliance}, + * extracted so {@link buildMedicationComplianceBundle} can build it once + * over the full ledger window instead of once per served sub-window. Same + * normalisation, same engine context, same rolling-retrospective routing. + */ +function buildTimelineForWindow( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + effectiveStart: Date, + effectiveDays: number, +): ReturnType { + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${i}`, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + daysOfWeek: s.daysOfWeek ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? null, + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + })); + + const engineCtx: CadenceEngineContext = { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + }; + + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + return buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + ctx.createdAt, + ctx.timeZone, + engineCtx, + retro, + ); +} diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index d927feac0..e5b406017 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -50,6 +50,18 @@ async function primeAllCaches(): Promise { c: 2, })); + // v1.15.20 — per-medication compliance payload cache. + await cached( + caches.medicationCompliance, + `${USER_A}|med-1|compliance`, + async () => ({ mc: 1 }), + ); + await cached( + caches.medicationCompliance, + `${USER_B}|med-1|compliance`, + async () => ({ mc: 2 }), + ); + await cached(caches.achievements, USER_A, async () => ({ ach: 1 })); await cached(caches.achievements, USER_B, async () => ({ ach: 2 })); @@ -149,6 +161,20 @@ describe("invalidateUserMedications", () => { expect(caches.medications.get(USER_B)).not.toBeNull(); expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull(); }); + + it("evicts the per-medication compliance payload for the target user only", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A); + + // v1.15.20 — the compliance route caches 15 min; an intake write must + // flush the `${userId}|` prefix so the next card read is fresh. + expect( + caches.medicationCompliance.get(`${USER_A}|med-1|compliance`), + ).toBeNull(); + expect( + caches.medicationCompliance.get(`${USER_B}|med-1|compliance`), + ).not.toBeNull(); + }); }); describe("invalidateUserDashboardWidgets", () => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index ada0837bf..6a14bc8ec 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -105,6 +105,10 @@ export function invalidateUserMood(userId: string): void { export function invalidateUserMedications(userId: string): void { caches.medications.deleteByPrefix(userId); caches.medicationsIntake.deleteByPrefix(`${userId}|`); + // v1.15.20 — the per-medication compliance payload is cached 15 min; + // every intake / medication / schedule write must flush it so the card + // rates and the heatmap reflect the action on the next read. + caches.medicationCompliance.deleteByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); // v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE // target rollup (compliance7 / compliance30 / consistency strip). diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 2ede5fe63..13818664b 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -393,6 +393,22 @@ export const caches = { maxEntries: 1000, ttlMs: 900_000, }), + /** + * v1.15.20 — per-medication compliance payload + * (`GET /api/medications/[id]/compliance`). The medications list fans + * the endpoint out once per card, and each cold build runs the full + * band-expansion pass — so the warm path has to live server-side. + * Keyed `${userId}|${medicationId}|compliance`; every intake / + * medication write flushes the `${userId}|` prefix via + * `invalidateUserMedications`, so the 15-minute TTL only bounds + * wall-clock drift (a dose flipping overdue), never user-action + * staleness. Sized for a few hundred users × a handful of meds. + */ + medicationCompliance: new ServerCache({ + name: "medicationCompliance", + maxEntries: 2000, + ttlMs: 900_000, + }), moodAnalytics: new ServerCache({ name: "moodAnalytics", maxEntries: 1000, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 4ee45cac4..1429b7aed 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -64,6 +64,29 @@ export function rateLimitHeaders( }; } +/** + * v1.15.20 — shared per-user budget for the heavy read-only analytics + * surfaces (`/api/insights/comprehensive`, `/api/insights/correlations`, + * `/api/insights/derived`, `/api/analytics/range`). The insights page + * legitimately fans several of these out per mount, so the bucket is + * generous — 120/min only caps a runaway client loop or a scripted + * drain, mirroring the `sync:changes` budget. One shared bucket (not + * per-surface) keeps the cap meaningful when a loop rotates across + * endpoints. + */ +const ANALYTICS_READ_LIMIT = 120; +const ANALYTICS_READ_WINDOW_MS = 60 * 1000; + +export async function checkAnalyticsReadRateLimit( + userId: string, +): Promise { + return checkRateLimit( + `analytics-read:${userId}`, + ANALYTICS_READ_LIMIT, + ANALYTICS_READ_WINDOW_MS, + ); +} + /** * v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every * caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the From 6f45d72a8469f200de963c927471b2df17f95328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 12:56:06 +0200 Subject: [PATCH 03/21] fix(ui): unclip the score-ring glow, reachable insight pills, consistent destructive actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The score ring's bloom was clipped by the SVG's own viewport — the drop-shadow sits on a child of the viewBox-filling arc — so the glow never extended past the circle; the SVG now overflows visibly on every host. The insight pill strip gains chevron buttons and wheel scrolling so an overflowing row is reachable with a mouse. Safari favourites rendered the transparent icon on a white plate; a flattened apple-touch-icon and a maskable manifest icon fix both. The time-of-day mood chart adopts the damped fill the weekday chart already uses, eighteen delete confirmations move onto the destructive button variant (one of them had the wrong text colour), five confirmation dialogs gain pending states, the sources dialog stacks its primary action on top on mobile, and the token form aligns input and button heights. --- messages/de.json | 4 +- messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + public/apple-touch-icon.png | Bin 0 -> 14474 bytes public/logo-maskable-512.png | Bin 0 -> 27376 bytes public/manifest.json | 12 +- src/app/layout.tsx | 5 +- src/components/admin/backups-section.tsx | 2 +- src/components/admin/danger-zone-section.tsx | 7 +- .../admin/feedback-inbox-section.tsx | 7 +- .../admin/user-management-section.tsx | 4 +- src/components/data-list/delete-button.tsx | 2 +- .../data-list/selection-action-bar.tsx | 2 +- .../insights/derived/score-ring.tsx | 10 +- .../insights/insights-tab-strip.tsx | 114 +++++++++++++++++- .../insights/mood/mood-time-of-day-chart.tsx | 19 +-- .../measurements/measurement-list.tsx | 2 +- src/components/mood/mood-form.tsx | 2 +- src/components/mood/mood-list.tsx | 2 +- src/components/settings/account-section.tsx | 14 ++- src/components/settings/advanced-section.tsx | 4 +- src/components/settings/api-section.tsx | 19 +-- .../settings/coach-memory-section.tsx | 11 +- .../settings/integrations-section.tsx | 6 +- src/components/settings/sources-section.tsx | 12 +- 28 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/logo-maskable-512.png diff --git a/messages/de.json b/messages/de.json index 430d15ebc..4026a4aa4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -834,7 +834,7 @@ "title": "Medikament beschreiben", "description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.", "placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag", - "submit": "Übernehmen", + "submit": "Extrahieren", "submitBusy": "Wird verarbeitet …", "cancel": "Abbrechen", "examples": { @@ -2099,6 +2099,8 @@ "navSteps": "Schritte", "navActiveEnergy": "Aktive Energie", "navAriaLabel": "Zu Abschnitt springen", + "pillScrollLeft": "Tabs nach links scrollen", + "pillScrollRight": "Tabs nach rechts scrollen", "tabStrip": { "vitalsParent": { "label": "Vitalwerte", diff --git a/messages/en.json b/messages/en.json index aac9080a9..ac85b895c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2099,6 +2099,8 @@ "navSteps": "Steps", "navActiveEnergy": "Active energy", "navAriaLabel": "Skip to section", + "pillScrollLeft": "Scroll tabs left", + "pillScrollRight": "Scroll tabs right", "tabStrip": { "vitalsParent": { "label": "Vitals", diff --git a/messages/es.json b/messages/es.json index b37cd76c0..07da460d5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2099,6 +2099,8 @@ "navSteps": "Pasos", "navActiveEnergy": "Energía activa", "navAriaLabel": "Saltar a sección", + "pillScrollLeft": "Desplazar las pestañas a la izquierda", + "pillScrollRight": "Desplazar las pestañas a la derecha", "tabStrip": { "vitalsParent": { "label": "Vitales", diff --git a/messages/fr.json b/messages/fr.json index 3399e0725..d6020b9c1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2099,6 +2099,8 @@ "navSteps": "Pas", "navActiveEnergy": "Énergie active", "navAriaLabel": "Aller à la section", + "pillScrollLeft": "Faire défiler les onglets vers la gauche", + "pillScrollRight": "Faire défiler les onglets vers la droite", "tabStrip": { "vitalsParent": { "label": "Constantes", diff --git a/messages/it.json b/messages/it.json index b56733170..72394ccb9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2099,6 +2099,8 @@ "navSteps": "Passi", "navActiveEnergy": "Energia attiva", "navAriaLabel": "Vai alla sezione", + "pillScrollLeft": "Scorri le schede a sinistra", + "pillScrollRight": "Scorri le schede a destra", "tabStrip": { "vitalsParent": { "label": "Vitali", diff --git a/messages/pl.json b/messages/pl.json index 71c5b8d23..632887aa3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2099,6 +2099,8 @@ "navSteps": "Kroki", "navActiveEnergy": "Energia czynna", "navAriaLabel": "Przejdź do sekcji", + "pillScrollLeft": "Przewiń karty w lewo", + "pillScrollRight": "Przewiń karty w prawo", "tabStrip": { "vitalsParent": { "label": "Witalne", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b71aacf83e10c04469d31871daec1c76a755ab6f GIT binary patch literal 14474 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 literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json index 8c3882279..d351c6fbb 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,12 +13,20 @@ { "src": "/logo-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/logo-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" + }, + { + "src": "/logo-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 422457f1e..32757cd95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -39,7 +39,10 @@ export const metadata: Metadata = { manifest: "/manifest.json", icons: { icon: "/favicon.svg", - apple: "/logo-192.png", + // Pre-flattened 180×180 on the app background — Safari ignores PNG + // alpha in favourites/home-screen tiles and would render the + // transparent logo on a white slab otherwise. + apple: "/apple-touch-icon.png", }, appleWebApp: { capable: true, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 9c82204e9..d60abcf4e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -127,7 +127,7 @@ function RestoreRowDialog({ {t("common.cancel")} { if (!matched) return; setOpen(false); diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index fcb45722b..4f42d3047 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -104,9 +104,14 @@ export function DangerZoneSection() { {t("common.cancel")} wipeAllData.mutate()} > + {wipeAllData.isPending && ( + + )} {t("admin.finalDelete")} diff --git a/src/components/admin/feedback-inbox-section.tsx b/src/components/admin/feedback-inbox-section.tsx index ebf92acf3..31180ed6c 100644 --- a/src/components/admin/feedback-inbox-section.tsx +++ b/src/components/admin/feedback-inbox-section.tsx @@ -398,8 +398,13 @@ function FeedbackDetailDialog({ {t("common.cancel")} archive.mutate()} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + variant="destructive" + disabled={archive.isPending} + aria-busy={archive.isPending || undefined} > + {archive.isPending && ( + + )} {t("admin.feedback.actionArchive")} diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index cc4f33dd9..38fa8a431 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -560,7 +560,9 @@ export function UserManagementSection() { forceLogout.mutate({ id: logoutTarget.id }); } }} - className="bg-destructive hover:bg-destructive/90" + variant="destructive" + disabled={forceLogout.isPending} + aria-busy={forceLogout.isPending || undefined} > {forceLogout.isPending ? ( diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx index dd53016e3..3252d381e 100644 --- a/src/components/data-list/delete-button.tsx +++ b/src/components/data-list/delete-button.tsx @@ -64,7 +64,7 @@ export function DeleteButton({ {cancelLabel ?? t("common.cancel")} {confirmLabel ?? t("common.delete")} diff --git a/src/components/data-list/selection-action-bar.tsx b/src/components/data-list/selection-action-bar.tsx index f7a48ea36..02cb5c5c9 100644 --- a/src/components/data-list/selection-action-bar.tsx +++ b/src/components/data-list/selection-action-bar.tsx @@ -105,7 +105,7 @@ export function SelectionActionBar({ {t("common.cancel")} diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 7cbd46b4a..83fa5f717 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -122,8 +122,7 @@ export function ScoreRing({ const shouldAnimate = animate && hasScore && !reduced; const dash = (clamped / 100) * C; - const hasBaseline = - baseline != null && Number.isFinite(baseline) && hasScore; + const hasBaseline = baseline != null && Number.isFinite(baseline) && hasScore; const baselineDash = hasBaseline ? (clampScore(baseline) / 100) * C : 0; // Arc starts empty (offset C) and sweeps to its fill on the next frame after @@ -170,9 +169,14 @@ export function ScoreRing({ } as React.CSSProperties } > + {/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`) + paints outside the viewBox — the arc fills the 0 0 100 100 box, so the + default `overflow: hidden` on SVG clipped the glow flat at the circle's + edge. The -90° rotation is unaffected (the bloom is radially symmetric) + and the parent div never clips, so the glow now feathers out fully. */}
-
+ {pillsOverflow && ( + + )} +
{tabs.map((tab) => { if (tab.kind === "link") { // The overview pill matches the mother page exactly; the @@ -631,6 +720,23 @@ function InsightsTabStripImpl({ ); })}
+ {pillsOverflow && ( + + )} {/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient absolute-positions over the rightmost ~24 px of the strip so the last visible pill softly fades into the background diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 7c2d43d19..e1e8f61bc 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -25,11 +25,7 @@ import { useTranslations } from "@/lib/i18n/context"; * a muted zero-height bar so the four-column rhythm stays intact. */ -export type MoodTimeOfDayBucket = - | "morning" - | "afternoon" - | "evening" - | "night"; +export type MoodTimeOfDayBucket = "morning" | "afternoon" | "evening" | "night"; export interface MoodTimeOfDayRow { bucket: MoodTimeOfDayBucket; @@ -88,23 +84,28 @@ export function MoodTimeOfDayChart({ data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }} > + {/* Muted chart language — same treatment as the weekday/distribution + siblings: `--muted-foreground` axis ticks, a quiet comment-grey + target guide, and 0.55 bar fill so the level hues inform without + shouting. */} - + {data.map((row) => ( diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ed63cf8f2..e964e3cf1 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -498,7 +498,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { {t("common.cancel")} {t("mood.formResetConfirmAction")} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index cfcb170cd..d62a6e97b 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -1018,7 +1018,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { {t("common.cancel")} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 8e546b13d..117dfbc23 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -940,9 +940,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { {t("common.cancel")} deletePasskey.mutate(pk.id)} > + {deletePasskey.isPending && ( + + )} {t("common.delete")} @@ -1022,9 +1027,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { {t("common.cancel")} deletePasskey.mutate(pk.id)} > + {deletePasskey.isPending && ( + + )} {t("common.delete")} diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index e1802e895..8d7340cab 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -327,7 +327,7 @@ function DataResetCard() { {t("common.cancel")} { e.preventDefault(); void handleDeleteAllData(); @@ -471,7 +471,7 @@ function AccountDeleteCard() { {t("common.cancel")} { e.preventDefault(); void handleDeleteAccount(); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index bc7f1835d..fad61ed8d 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -134,13 +134,13 @@ function ApiEndpointsCard() { {endpoint.path}
-

+

{t("settings.apiEndpointAuth")}

{endpoint.auth}
-

+

{t("settings.apiEndpointExample")}

@@ -263,7 +263,10 @@ function ApiTokensCard() { />
-
+ {/* The button mirrors the Input's responsive height (h-11 / sm:h-10) + so the row reads as one aligned control group instead of a tall + input next to a shorter `sm` button. */} + setNewName(e.target.value)} @@ -274,10 +277,12 @@ function ApiTokensCard() {
@@ -416,7 +421,7 @@ function ApiTokensCard() { {t("common.cancel")} handleRevoke(tok.id)} > {t("settings.tokenRevoked")} @@ -515,7 +520,7 @@ function ApiTokensCard() { {t("common.cancel")} handleRevoke(tok.id)} > {t("settings.tokenRevoked")} diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index dde6912bf..77fd4aad8 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -197,8 +197,13 @@ export function CoachMemorySection({ forgetAll.mutate()} > + {forgetAll.isPending && ( + + )} {t("settings.ai.coachMemory.forgetAllConfirmAction")} @@ -208,7 +213,11 @@ export function CoachMemorySection({
{query.isError && ( -

+

{t("settings.ai.coachMemory.loadError")}

)} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index afdc9d224..91c9dcf03 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -622,7 +622,7 @@ function WithingsCard({ {t("common.cancel")} disconnect.mutate()} > {t("settings.withingsDisconnect")} @@ -1035,7 +1035,7 @@ function WhoopCard({ {t("common.cancel")} disconnect.mutate()} > {t("settings.whoopDisconnect")} @@ -1481,7 +1481,7 @@ function FitbitCard({ {t("common.cancel")} disconnect.mutate()} > {t("settings.fitbitDisconnect")} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 232162404..35ece6b40 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -387,7 +387,8 @@ export function SourcesSection() { className="h-11 w-11" onClick={() => moveSource(metric, index, 1)} disabled={ - index === list.length - 1 || saveMutation.isPending + index === list.length - 1 || + saveMutation.isPending } aria-label={t("settings.sections.sources.moveDown")} > @@ -536,9 +537,7 @@ export function SourcesSection() { variant="ghost" size="icon" className="h-11 w-11" - onClick={() => - moveDeviceType(null, index, -1) - } + onClick={() => moveDeviceType(null, index, -1)} disabled={index === 0 || saveMutation.isPending} aria-label={t("settings.sections.sources.moveUp")} > @@ -566,8 +565,11 @@ export function SourcesSection() {
)} + {/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary + action stacks on top on mobile, row order stays Cancel → Save + on `sm+`. */} {dirty && priority && ( -
+
- } > - - {importOpen && ( - setImportOpen(false)} - /> - )} {/* INJEKTION — injectable routes only. Current GLP-1 charts + @@ -545,6 +689,45 @@ export function MedicationDetailTabs({ ); } +/** + * One Übersicht status row: label + value on the left, a quiet jump link + * into the owning tab on the right. The link is a button (the tab switch + * is shallow URL state, not a navigation) styled as an inline text link. + */ +function StatusRow({ + label, + value, + jumpLabel, + onJump, + dataSlot, +}: { + label: string; + value: ReactNode; + jumpLabel: string; + onJump: () => void; + dataSlot: string; +}) { + return ( +
  • +
    +

    {label}

    +

    {value}

    +
    + +
  • + ); +} + /** * The danger zone's purge gate needs `intakeCount`. Fetch it lazily here * (the Erweitert tab pays for it; the other tabs never do) rather than diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts new file mode 100644 index 000000000..0c31761f1 --- /dev/null +++ b/src/components/medications/detail/supply-runway.ts @@ -0,0 +1,69 @@ +/** + * v1.15.20 — supply-runway estimate for the medication detail Übersicht. + * + * Pure helpers that turn the schedule snapshot the detail GET already + * carries into an approximate doses-per-day figure, so the Übersicht tab + * can render "lasts about N more days" from the inventory remaining-doses + * count without a new endpoint. The estimate is deliberately coarse + * (calendar months ≈ 30 days) — the copy says "about" and the Bestand tab + * stays the authoritative per-item readout. + */ + +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; + +/** The schedule fields the runway estimate reads. */ +export interface RunwaySchedule { + windowStart: string; + daysOfWeek: string | null; + timesOfDay?: string[]; + rrule?: string | null; + rollingIntervalDays?: number | null; +} + +/** + * Approximate doses per day across every schedule: times-of-day count, + * scaled down by the cadence (rolling interval, weekly day picks + + * interval weeks, monthly/yearly RRULEs). + */ +export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { + let perDay = 0; + for (const s of schedules) { + const times = + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; + if ( + typeof s.rollingIntervalDays === "number" && + s.rollingIntervalDays >= 1 + ) { + perDay += times / s.rollingIntervalDays; + continue; + } + const rrule = s.rrule ?? ""; + if (/FREQ=MONTHLY/.test(rrule)) { + perDay += times / 30; + continue; + } + if (/FREQ=YEARLY/.test(rrule)) { + perDay += times / 365; + continue; + } + const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); + const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; + const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; + perDay += (times * daysPerWeek) / (7 * weeks); + } + return perDay; +} + +/** + * Whole days the remaining supply covers, or `null` when no estimate is + * possible (no supply, no consuming schedule). + */ +export function estimateRunwayDays( + dosesRemaining: number, + schedules: RunwaySchedule[], +): number | null { + if (dosesRemaining <= 0) return null; + const perDay = estimateDailyDoseCount(schedules); + if (perDay <= 0) return null; + return Math.floor(dosesRemaining / perDay); +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 138ebe900..c0de3fc59 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -10,13 +10,16 @@ * day first, chronological inside the day. * * Three interactions land here: - * - a pending / missed slot exposes Genommen / Übersprungen ON the row - * (~2 taps, not buried), which mark the dose with INSTANT feedback — the + * - an actionable slot (upcoming, or missed today) exposes ONE visible + * [Genommen] button that marks the dose with INSTANT feedback — the * cached ledger + the headline % are mutated optimistically (the engines * are pure, so the client mirrors their accounting) before the server - * write fires and the authoritative refetch reconciles; - * - any row with an intake can be edited (time / skipped) or deleted via the - * shared `` + delete confirm; + * write fires and the authoritative refetch reconciles. The success + * toast carries an Undo. Übersprungen and the backfill of older missed + * slots live in the row's overflow kebab; + * - settled rows (taken / skipped / a past missed day) carry ONLY the + * kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen — so the + * ledger reads as a calm record, not a button wall; * - a header "+ Eintrag" opens an add dialog scoped to this medication * (incl. backdated / off-schedule); an off-window take surfaces the * "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via @@ -35,13 +38,23 @@ import { Check, CircleCheck, Loader2, + MoreVertical, + Pencil, Plus, SkipForward, + Trash2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, @@ -60,6 +73,7 @@ import { queryKeys, } from "@/lib/query-keys"; import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; +import { runUndoIntake } from "@/components/medications/use-medication-intake"; import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog"; import { @@ -177,11 +191,26 @@ export function DoseHistoryLedger({ [data, timeZone], ); + // Today's local day key — a missed slot from TODAY is still in active + // play (visible Genommen button); older missed days settle into the + // row kebab so the history reads calm. + const todayKey = useMemo( + () => + new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()), + [timeZone], + ); + /** * Genommen / Übersprungen on a pending / missed slot. The cached ledger is * mutated optimistically (row status + the headline % flip in this paint), * then the server write fires and the dependent-key invalidation reconciles. - * A failed write rolls the optimistic snapshot back + toasts (mirrors the + * A failed write rolls the optimistic snapshot back + toasts; a successful + * one carries the same Undo action the medication cards offer (mirrors the * C1/C2 pattern in `use-medication-intake.ts`). */ const markSlot = useCallback( @@ -220,6 +249,15 @@ export function DoseHistoryLedger({ ); return; } + // The POST returns the created event; its id drives the Undo + // affordance (the same soft-delete route the cards use). + let eventId: string | undefined; + try { + const json = await res.json(); + eventId = json?.data?.id as string | undefined; + } catch { + /* dose recorded; the body is best-effort for the id */ + } toast.success( t( action === "skipped" @@ -227,6 +265,20 @@ export function DoseHistoryLedger({ : "medications.intakeToastTaken", { name: medicationName }, ), + eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => + void runUndoIntake({ + medication: { id: medicationId, name: medicationName }, + eventId, + t, + queryClient, + }), + }, + } + : undefined, ); await invalidateKeys(queryClient, [ ...medicationDependentKeys, @@ -244,6 +296,49 @@ export function DoseHistoryLedger({ [marking, medicationId, medicationName, queryClient, queryKey, t], ); + /** + * Flip an existing intake between Übersprungen and Genommen from the row + * kebab. Same `PUT intake/[eventId]` contract the edit dialog uses; a + * skipped→taken flip anchors `takenAt` on the slot instant (a past slot) + * rather than "now". + */ + const toggleIntakeSkipped = useCallback( + async (intake: { + id: string; + skipped: boolean; + scheduledFor: string | null; + }) => { + try { + const body = intake.skipped + ? { + skipped: false, + takenAt: intake.scheduledFor ?? new Date().toISOString(), + } + : { skipped: true, takenAt: null }; + const res = await fetch( + `/api/medications/${medicationId}/intake/${intake.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.intake.edit.failed")); + return; + } + await invalidateKeys(queryClient, [ + ...medicationDependentKeys, + queryKey, + ]); + toast.success(t("medications.detail.intake.edit.savedToast")); + } catch { + toast.error(t("medications.detail.intake.edit.failed")); + } + }, + [medicationId, queryClient, queryKey, t], + ); + async function confirmRowDelete() { if (!pendingDeleteId) return; const id = pendingDeleteId; @@ -330,7 +425,9 @@ export function DoseHistoryLedger({ key={`${row.kind}:${row.at}:${row.intake?.id ?? i}`} row={row} marking={marking} + isToday={group.dayKey === todayKey} onMark={markSlot} + onToggleSkipped={toggleIntakeSkipped} onEdit={(ev) => setEditingEvent(ev)} onDelete={(id) => setPendingDeleteId(id)} /> @@ -367,20 +464,34 @@ export function DoseHistoryLedger({ } /** - * One ledger row: time + status pill + (when an intake is attributed) its real - * take time, plus the inline actions. A pending/missed slot offers Genommen / - * Übersprungen; a row with an intake offers Bearbeiten / Löschen. + * One ledger row: time + status (colour + glyph + text) + (when an intake is + * attributed) its real take time, plus the row actions. + * + * Action shape (v1.15.20): an actionable slot that is still in play + * (upcoming, or missed TODAY) carries exactly ONE visible [Genommen] button — + * label visible on every viewport — plus the overflow kebab (Übersprungen). + * Every settled row (taken / skipped / a past missed day / ad-hoc) carries + * ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen — + * and, for a past missed slot, the Genommen / Übersprungen backfill marks. */ function LedgerRowItem({ row, marking, + isToday, onMark, + onToggleSkipped, onEdit, onDelete, }: { row: LedgerRow; marking: string | null; + isToday: boolean; onMark: (row: LedgerRow, action: "taken" | "skipped") => void; + onToggleSkipped: (intake: { + id: string; + skipped: boolean; + scheduledFor: string | null; + }) => void; onEdit: (event: { id: string; takenAt: string | null; @@ -393,6 +504,10 @@ function LedgerRowItem({ const fmt = useFormatters(); const actionable = isSlotActionable(row); + // A missed slot from an earlier day stays markable, but only through the + // kebab — the visible button is reserved for doses still in play. + const showTakenButton = + actionable && (row.status === "upcoming" || isToday); const intake = row.intake; const takeBusy = marking === `${row.at}:taken`; const skipBusy = marking === `${row.at}:skipped`; @@ -445,81 +560,125 @@ function LedgerRowItem({
    - {actionable && ( - <> + {showTakenButton && ( + + )} + + + + + {actionable && !showTakenButton && ( + onMark(row, "taken")} + data-slot="ledger-menu-taken" + > + + )} + {intake?.id && ( + <> + + onEdit({ + id: intake.id as string, + takenAt: intake.takenAt, + skipped: intake.skipped, + scheduledFor: intake.scheduledFor, + }) + } + data-slot="ledger-edit" + > + + {(row.status === "skipped" || + row.status === "taken_on_time" || + row.status === "taken_late") && ( + + onToggleSkipped({ + id: intake.id as string, + skipped: intake.skipped, + scheduledFor: intake.scheduledFor, + }) + } + data-slot="ledger-toggle-skipped" + > + {intake.skipped ? ( + + )} + + onDelete(intake.id as string)} + className="text-destructive focus:text-destructive" + data-slot="ledger-delete" + > + + + )} + +
    ); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index a9fdc9433..f151d14dd 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -30,7 +30,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance"; /** * v1.4.25 W4d — GLP-1 medication card variant. * - * Marc directive 2026-05-14: NO chart on the medication card. The card + * Maintainer directive 2026-05-14: NO chart on the medication card. The card * stays text-rich (drug name + current dose, last/next injection, * injection-site rotation hint, side-effect quick-log). v1.4.28 * retired the inline dose-history disclosure and the inventory @@ -129,12 +129,6 @@ interface Glp1MedicationCardProps { * card. The parent owns the navigation. */ onOpenHistory: (med: Glp1Medication) => void; - /** - * v1.15.18 — navigates to the medication detail page's Erweitert tab - * (`/medications/{id}?tab=erweitert`), the home of the dissolved - * advanced settings. - */ - onOpenAdvanced: (med: Glp1Medication) => void; onLogSideEffect?: (med: Glp1Medication) => void; } @@ -167,7 +161,6 @@ export function Glp1MedicationCard({ medication, onEdit, onOpenHistory, - onOpenAdvanced, onLogSideEffect, }: Glp1MedicationCardProps) { const queryClient = useQueryClient(); @@ -364,7 +357,6 @@ export function Glp1MedicationCard({ onEdit(medication)} onOpenHistory={() => onOpenHistory(medication)} - onOpenAdvanced={() => onOpenAdvanced(medication)} onLogSideEffect={ onLogSideEffect ? () => onLogSideEffect(medication) : undefined } diff --git a/src/components/medications/medication-card-menu.tsx b/src/components/medications/medication-card-menu.tsx index cec65b51f..86bb31b3d 100644 --- a/src/components/medications/medication-card-menu.tsx +++ b/src/components/medications/medication-card-menu.tsx @@ -7,8 +7,11 @@ * collapsed their former four header icon-buttons (open / edit / history * / advanced) into a single overflow kebab so the card header carries one * affordance instead of a cluster. The card body itself navigates to the - * detail page (the former chevron target); this menu owns the edit / - * history / advanced actions. + * detail page (the former chevron target); this menu owns the edit and + * history actions. v1.15.20 retires the "Advanced settings" item — the + * detail page's Erweitert tab is reachable through the card body, and + * Edit now opens the medication wizard directly instead of landing on + * the Zeitplan tab. * * The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a * "Log side effect" item into the SAME menu, but it is NOT wired on the @@ -28,7 +31,7 @@ * navigation. */ -import { History, MoreVertical, Pencil, SlidersHorizontal, Stethoscope } from "lucide-react"; +import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -43,7 +46,6 @@ import { useTranslations } from "@/lib/i18n/context"; export interface MedicationCardMenuProps { onEdit: () => void; onOpenHistory: () => void; - onOpenAdvanced: () => void; /** GLP-1-only: folds the side-effect quick-log into the same menu. */ onLogSideEffect?: () => void; } @@ -51,7 +53,6 @@ export interface MedicationCardMenuProps { export function MedicationCardMenu({ onEdit, onOpenHistory, - onOpenAdvanced, onLogSideEffect, }: MedicationCardMenuProps) { const { t } = useTranslations(); @@ -85,10 +86,6 @@ export function MedicationCardMenu({ {t("medications.detail.header.historyLabel")} - - - {t("medications.detail.header.advancedLabel")} - {onLogSideEffect && ( <> diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 9ee34e52b..861ea278a 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -98,19 +98,12 @@ interface MedicationCardProps { * (`/medications/{id}?tab=verlauf`). The parent owns the navigation. */ onOpenHistory: (med: Medication) => void; - /** - * v1.15.18 — navigates to the medication detail page's Erweitert tab - * (`/medications/{id}?tab=erweitert`), the home of the dissolved - * advanced settings. - */ - onOpenAdvanced: (med: Medication) => void; } export function MedicationCard({ medication, onEdit, onOpenHistory, - onOpenAdvanced, }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); @@ -288,7 +281,6 @@ export function MedicationCard({ onEdit(medication)} onOpenHistory={() => onOpenHistory(medication)} - onOpenAdvanced={() => onOpenAdvanced(medication)} /> ); diff --git a/src/components/medications/medication-detail-section.tsx b/src/components/medications/medication-detail-section.tsx index a6157e53c..86ee544b2 100644 --- a/src/components/medications/medication-detail-section.tsx +++ b/src/components/medications/medication-detail-section.tsx @@ -9,19 +9,18 @@ import { type ReactNode } from "react"; * divider + body padding — and drifted on padding, border opacity, and * aria wiring. * - * v1.4.28 FB-F3 / F4 — heading scale collapsed to one shape across the - * `/medications/[id]` surface so the page reads as one consistent - * document. Title classes now match the settings symmetry sweep: - * `text-base font-semibold leading-6 tracking-tight`. Body band keeps - * its `text-xs` density; one heading scale (`text-base font-semibold`), - * one body scale (`text-sm`), one micro scale (`text-xs`) on the page. + * v1.15.20 — lifted to the card language the rest of the detail page + * speaks (`SettingsGroup`, the dashboard cards): `bg-card` surface, + * `rounded-xl`, `p-4 md:p-6` padding. The body band reads at `text-sm` + * (the standard body scale) instead of the former `text-xs` container, + * so every tab renders the same surface vocabulary. * * Contract: - * - `border-border/60 rounded-md border` chrome - * - `px-3 py-2.5` header row with + * - `bg-card rounded-xl border p-4 md:p-6` chrome + * - header row with * `text-foreground text-base font-semibold leading-6 tracking-tight` - * title - * - `border-border/60 border-t px-3 py-3 text-xs` body band + * title, separated from the body by a `border-border/60` rule + * - `pt-3 text-sm` body band * - `aria-labelledby` wired to a stable per-section id derived from `titleId` * * Consumers pass: @@ -35,9 +34,6 @@ import { type ReactNode } from "react"; * - `dataSlot` — optional `data-slot` attribute on the outer section * so cross-section selectors (Playwright, snapshot tests) stay * stable. - * - `bodyPaddingY` — escape hatch for sections that need a different - * vertical padding (the side-effects body runs at py-2.5 because - * it owns an inline timeline with its own gap rhythm). */ export interface MedicationDetailSectionProps { titleId: string; @@ -45,7 +41,6 @@ export interface MedicationDetailSectionProps { headerExtras?: ReactNode; children: ReactNode; dataSlot?: string; - bodyPaddingY?: "py-2.5" | "py-3"; } export function MedicationDetailSection({ @@ -54,15 +49,14 @@ export function MedicationDetailSection({ headerExtras, children, dataSlot, - bodyPaddingY = "py-3", }: MedicationDetailSectionProps) { return (
    -
    +

    {headerExtras}

    -
    - {children} -
    +
    {children}
    ); } diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index e5eae9751..2cae2fdb6 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -87,7 +87,7 @@ export function MedicationDetailSummary({ return (
    -

    {name}

    +

    {name}

    {dose}

    void; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -238,19 +235,6 @@ export function ScheduleTimesEditor({ {t("medications.detail.zeitplan.graceEcho", { minutes: grace, })} - {onRequestReminderTab ? ( - <> - {" · "} - - - ) : null}

    ) : null}
    diff --git a/src/components/medications/scheduling/TimesOfDayChips.tsx b/src/components/medications/scheduling/TimesOfDayChips.tsx index b166b6293..d2f200ce1 100644 --- a/src/components/medications/scheduling/TimesOfDayChips.tsx +++ b/src/components/medications/scheduling/TimesOfDayChips.tsx @@ -159,7 +159,7 @@ export function TimesOfDayChips({ if (!TIME_RE.test(v)) return; onChange([v]); }} - className="h-11 w-32" + className="h-11 w-32 sm:h-9" aria-label={t(`${i18nPrefix}.label`)} />
    @@ -195,7 +195,10 @@ export function TimesOfDayChips({ data-active={isOn ? "true" : "false"} onClick={() => onPresetToggle(preset)} className={[ - "focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50", + // 44 px tap floor on mobile, 36 px on sm+ — the same + // height system the dose-window inputs + save button use + // so the Zeitplan tab renders one control height. + "focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 sm:h-9", isOn ? "bg-primary text-primary-foreground border-primary" : "border-border bg-background text-foreground hover:bg-muted", @@ -227,7 +230,7 @@ export function TimesOfDayChips({ key={time} data-slot="times-of-day-chip" data-time={time} - className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium" + className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium sm:h-9" > {time} + } + /> ) : ( (regenerating); useEffect(() => { if (lastRegeneratingRef.current && !regenerating) { if (regenerateOutcome === "timeout") { toast.error(t("insights.regenerateError")); + } else if (regenerateOutcome === "rate-limited") { + toast.error(t("insights.regenerateRateLimited")); + } else if (regenerateOutcome === "empty") { + toast.error(t("insights.regenerateUnavailable")); } else if (regenerateOutcome !== "no-provider") { - // `"fresh"`, `"empty"`, or legacy `undefined`/null → success. + // `"fresh"` or legacy `undefined`/null → success. toast.success(t("insights.regenerateSuccess")); } } diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index ef159ec94..60734f65f 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -78,11 +78,17 @@ const FORCE_ADVISOR_TIMEOUT_MS = 45_000; * v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST: * only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen * the client gave up on) and a `no-provider` (422) are distinct failure - * modes the audit's Fix 2 + Fix 4 call out. + * modes. + * + * v1.15.20 — `rate-limited` (429) splits off from `empty`: the user's + * regenerate quota is exhausted, which deserves a "try again later" + * hint rather than the success toast the old lump produced. `empty` now + * means only the transient 503 surface (provider chain unavailable). */ export type AdvisorFetchOutcome = | "fresh" | "empty" + | "rate-limited" | "timeout" | "no-provider"; @@ -128,15 +134,19 @@ async function fetchAdvisor( clearTimeout(timeoutHandle); } if (!res.ok) { - // 422 (no provider configured) and 429 (rate-limited) are expected - // surfaces — return null so the consuming UI shows the empty / error - // state without the query slipping into an `isError` retry loop. 422 - // surfaces as `no-provider` so the regenerate path can distinguish a - // missing provider from a slow generation (audit Fix 4). + // 422 (no provider configured), 429 (rate-limited), and 503 + // (provider chain unavailable) are expected surfaces — return null + // so the consuming UI shows the empty / error state without the + // query slipping into an `isError` retry loop. Each gets its own + // outcome tag so the regenerate toast can be honest about WHY no + // fresh payload arrived. if (res.status === 422) { return { payload: null, outcome: "no-provider" }; } - if (res.status === 429 || res.status === 503) { + if (res.status === 429) { + return { payload: null, outcome: "rate-limited" }; + } + if (res.status === 503) { return { payload: null, outcome: "empty" }; } throw new Error(`HTTP ${res.status}`); @@ -191,6 +201,14 @@ export interface UseInsightsAdvisorResult { * (`"no-provider"`) never reads as "done". */ regenerateOutcome: AdvisorFetchOutcome | null; + /** + * v1.15.20 — the outcome of the last settled READ. Lets surfaces + * distinguish "no briefing yet, a generate could help" (`empty` / + * `timeout`) from "no provider configured, generating is futile" + * (`no-provider`) and render a connect-AI hint instead of a dead + * regenerate CTA. + */ + readOutcome: AdvisorFetchOutcome | null; } /** @@ -203,9 +221,10 @@ export function useInsightsAdvisorQuery( const queryClient = useQueryClient(); const query = useQuery({ queryKey: queryKeys.insightsAdvisor(), - // Map the tagged fetch result back onto the payload the cache stores — - // the read path only cares about the payload, not the outcome tag. - queryFn: async () => (await fetchAdvisor()).payload, + // v1.15.20 — the cache stores the full tagged result (payload + + // outcome) so the read path can surface WHY a payload is missing + // (`no-provider` → connect-AI hint instead of a dead regenerate CTA). + queryFn: () => fetchAdvisor(), enabled, // 24h cache window matches the server-side `insightsCachedAt` TTL. staleTime: 60 * 60 * 1000, @@ -214,11 +233,11 @@ export function useInsightsAdvisorQuery( const mutation = useMutation({ mutationFn: () => fetchAdvisor({ force: true }), - onSuccess: ({ payload }) => { - if (payload) { + onSuccess: (result) => { + if (result.payload) { // A genuinely fresh generation landed — write it into the shared // cache so the hero subtitle + briefing card repaint immediately. - queryClient.setQueryData(queryKeys.insightsAdvisor(), payload); + queryClient.setQueryData(queryKeys.insightsAdvisor(), result); } else { // No fresh payload (timeout / no-provider / transient). The server's // inline POST may STILL have written a fresh briefing after the @@ -245,7 +264,7 @@ export function useInsightsAdvisorQuery( const regenerate = useCallback(() => mutate(), [mutate]); return { - payload: query.data ?? null, + payload: query.data?.payload ?? null, isLoading: query.isLoading, isError: query.isError, error: (query.error as Error | null) ?? null, @@ -253,5 +272,6 @@ export function useInsightsAdvisorQuery( isRegenerating: mutation.isPending, regenerateError: (mutation.error as Error | null) ?? null, regenerateOutcome: mutation.data?.outcome ?? null, + readOutcome: query.data?.outcome ?? null, }; } diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 1bf6a8963..f6bd6973f 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -56,6 +56,7 @@ function buildUser(disableCoach: boolean): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference: "metric", + timeFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e54166c25..7d30a5d59 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -755,7 +755,8 @@ async function buildCoachSnapshotImpl( : null; const intakeRowsPromise = wantsCompliance ? prisma.medicationIntakeEvent.findMany({ - where: { userId, scheduledFor: { gte: cutoff } }, + // Tombstoned intake rows must never reach the Coach snapshot. + where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } }, orderBy: { scheduledFor: "asc" }, select: { scheduledFor: true, takenAt: true, skipped: true }, }) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 90eda9d1d..049da4605 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -313,6 +313,116 @@ export async function resolveProviderChain( return resolved; } +/** + * Credential-presence subset of the `User` row that + * `userRowHasProviderCredential` evaluates. Mirrors the columns + * `resolveProviderChain` / `resolveProvider` read, but presence-only — + * no decrypt, no client construction, no token refresh. + */ +export interface ProviderCredentialRow { + aiProvider: string | null; + aiProviderChain: unknown; + aiAnthropicKeyEncrypted: string | null; + aiLocalKeyEncrypted: string | null; + aiOpenaiKeyEncrypted: string | null; + aiBaseUrl: string | null; + codexConnectionStatus: string | null; + codexAccessTokenEncrypted: string | null; + codexRefreshTokenEncrypted: string | null; +} + +/** + * Cheap, synchronous "is any provider configured?" check over an already + * loaded credential row. Mirrors the resolution semantics of + * `resolveProviderChain` + the legacy `resolveProvider` fallback (the + * exact pair `generateComprehensiveInsight` uses to decide + * `skipped: no-provider`), but evaluates credential PRESENCE only — + * it never decrypts a key, constructs a client, or refreshes a Codex + * token. A `true` here can still resolve to a dead provider at call + * time (revoked key, unreachable local host); callers use it to decide + * whether a generation is worth attempting at all, not as a liveness + * probe. + * + * `adminKeyConfigured` is the presence of `appSettings.adminAiKeyEncrypted` + * — passed in so batch callers can read it once for a whole cohort. + */ +export function userRowHasProviderCredential( + row: ProviderCredentialRow, + adminKeyConfigured: boolean, +): boolean { + const codexConnected = + row.codexConnectionStatus === "connected" && + !!row.codexAccessTokenEncrypted && + !!row.codexRefreshTokenEncrypted; + + const chain = parseProviderChain(row.aiProviderChain ?? null).filter( + (e) => e.enabled, + ); + for (const entry of chain) { + switch (entry.providerType) { + case "codex": + if (codexConnected) return true; + break; + case "openai": + if (row.aiOpenaiKeyEncrypted) return true; + break; + case "anthropic": + if (row.aiAnthropicKeyEncrypted) return true; + break; + case "local": + if (row.aiBaseUrl) return true; + break; + case "admin-openai": + if (adminKeyConfigured) return true; + break; + } + } + + // Legacy `resolveProvider()` fallback — only reached when the chain + // resolves empty. Mirrors buildUserProvider → codex → admin in order. + const choice = row.aiProvider?.toUpperCase(); + if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true; + if (choice === "LOCAL" && row.aiBaseUrl) return true; + if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true; + if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true; + return adminKeyConfigured; +} + +/** + * Async single-user variant of `userRowHasProviderCredential`: two + * narrow presence reads (user credential columns + the admin key flag), + * no decrypt, no network. Used by read paths that only need to know + * whether a generation could ever produce provider-backed text (e.g. + * the dashboard snapshot's `briefingState: "no-provider"`). + */ +export async function hasAnyConfiguredProvider( + userId: string, +): Promise { + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + }, + }); + if (!userRow) return false; + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + return userRowHasProviderCredential( + userRow, + !!settings?.adminAiKeyEncrypted, + ); +} + /** * Materialise a single chain entry. Returns null when the user lacks * the matching credential — the chain runner skips null entries. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b8fa2643f..991e3dcc8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -6,7 +6,8 @@ * - every above-the-fold tile field is present in the envelope; * - the two-phase contract: `extras` is null on a rollup-coverage * miss, populated when warm; - * - the briefingState matrix (ready / preparing / disabled); + * - the briefingState matrix (ready / preparing / disabled / + * no-provider) plus the stale-briefing delivery contract; * - NO LLM client is reachable from the builder (the provider chain * is never imported by `snapshot.ts` — asserted structurally). */ @@ -19,6 +20,7 @@ const readMoodDayRollups = vi.fn(); const ensureUserMoodRollupsFresh = vi.fn(); const computeBpInTargetFastPath = vi.fn(); const getAssistantFlags = vi.fn(); +const hasAnyConfiguredProvider = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), @@ -39,6 +41,11 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), })); +// The builder imports only the credential-PRESENCE check; mock it so the +// test never touches the real provider module (prisma / crypto imports). +vi.mock("@/lib/ai/provider", () => ({ + hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), +})); import { buildDashboardSnapshot, @@ -104,6 +111,7 @@ beforeEach(() => { }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); + hasAnyConfiguredProvider.mockResolvedValue(true); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -211,7 +219,41 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { expect(snap.briefing!.paragraph).toContain("grünen"); }); - it("preparing — cache older than 24h", async () => { + it("preparing — cache older than 24h still DELIVERS the stale briefing with briefingStale: true", async () => { + const cachedAt = new Date(Date.now() - 25 * 60 * 60 * 1000); + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: cachedAt, + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + ); + expect(snap.briefingState).toBe("preparing"); + // v1.15.20 — the last good briefing rides along instead of a blank + // tile; the stale flag + timestamp carry the honesty. + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingStale).toBe(true); + expect(snap.briefingUpdatedAt).toBe(cachedAt.toISOString()); + }); + + it("preparing — never generated (null cache)", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(snap.briefingState).toBe("preparing"); + expect(snap.briefing).toBeNull(); + expect(snap.briefingStale).toBe(false); + }); + + it("no-provider — stale cache and no provider configured anywhere", async () => { + hasAnyConfiguredProvider.mockResolvedValue(false); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(snap.briefingState).toBe("no-provider"); + expect(snap.briefing).toBeNull(); + }); + + it("no-provider — still delivers a stale briefing when one exists", async () => { + hasAnyConfiguredProvider.mockResolvedValue(false); const snap = await buildDashboardSnapshot( fakePrisma, baseUser({ @@ -221,14 +263,22 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { }), }), ); - expect(snap.briefingState).toBe("preparing"); - expect(snap.briefing).toBeNull(); + expect(snap.briefingState).toBe("no-provider"); + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingStale).toBe(true); }); - it("preparing — never generated (null cache)", async () => { - const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.briefingState).toBe("preparing"); - expect(snap.briefing).toBeNull(); + it("ready — the provider presence probe never runs on a fresh cache", async () => { + await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + ); + expect(hasAnyConfiguredProvider).not.toHaveBeenCalled(); }); it("disabled — coach surface off (flag)", async () => { @@ -453,9 +503,10 @@ describe("buildDashboardSnapshot — additive proof", () => { for (const key of legacyKeys) { expect(snap).toHaveProperty(key); } - // The two new additive blocks sit alongside, not in place of. + // The new additive blocks sit alongside, not in place of. expect(snap).toHaveProperty("metricStates"); expect(snap).toHaveProperty("layoutCatalogue"); + expect(snap).toHaveProperty("briefingStale"); // The web-consumed `layout` block is the resolved per-user layout — // NOT replaced by the 35-id catalogue. diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 71ec5be29..945ee4f14 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -28,8 +28,11 @@ * * No LLM is reachable from this builder. The briefing is lifted * read-only from `User.insightsCachedText`; a cache miss / stale row - * yields `briefingState: "preparing"` and the `insight-pregenerate` - * cron refills it. The builder NEVER POSTs `/api/insights/generate`. + * yields `briefingState: "preparing"` (with the last good briefing + * delivered under `briefingStale: true` when one exists) and the + * `insight-pregenerate` cron refills it — or `"no-provider"` when no + * AI provider is configured anywhere, so clients can stop waiting. + * The builder NEVER POSTs `/api/insights/generate`. */ import type { PrismaClient, @@ -61,6 +64,7 @@ import { } from "@/lib/dashboard-layout"; import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { hasAnyConfiguredProvider } from "@/lib/ai/provider"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ @@ -137,7 +141,14 @@ const METRIC_KIND_RAW_BY_TYPE: Partial> = { TIME_IN_DAYLIGHT: "timeInDaylight", }; -export type BriefingState = "ready" | "preparing" | "disabled"; +/** + * v1.15.20 — `"no-provider"` joins the tri-state: the cache is stale or + * missing AND no AI provider is configured anywhere (user chain, legacy + * selection, Codex link, operator key), so no warm pass will ever fill + * it. Clients render a "connect a provider" hint instead of an eternal + * "preparing" spinner. Existing states keep their exact semantics. + */ +export type BriefingState = "ready" | "preparing" | "disabled" | "no-provider"; export interface DashboardSnapshotUser { username: string; @@ -233,6 +244,15 @@ export interface DashboardSnapshot { briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; + /** + * v1.15.20 — additive honesty flag. `true` when `briefing` carries the + * LAST GOOD (expired-TTL) briefing while a refresh is pending + * (`briefingState: "preparing"`) or will never come + * (`"no-provider"`). Clients show the stale content with its + * `briefingUpdatedAt` timestamp instead of a blank tile — the same + * stale-while-revalidate honesty the insights page uses. + */ + briefingStale: boolean; generatedAt: string; } @@ -430,24 +450,53 @@ async function buildExtras( }; } +/** Parse + validate the cached `dailyBriefing` block; null when unusable. */ +function parseCachedBriefing( + cachedText: string | null, +): DailyBriefing | null { + if (!cachedText) return null; + try { + const parsed = JSON.parse(cachedText) as Record; + const candidate = parsed?.dailyBriefing; + if (candidate == null) return null; + const validated = dailyBriefingSchema.safeParse(candidate); + return validated.success ? validated.data : null; + } catch { + return null; + } +} + /** * Read-only briefing lift. Parses `User.insightsCachedText`, validates - * the `dailyBriefing` block, and reports a tri-state. NEVER calls the - * provider chain. + * the `dailyBriefing` block, and reports a four-state. NEVER calls the + * provider chain — `hasProvider` is a credential-PRESENCE thunk (two + * narrow reads, no decrypt, no network) evaluated only on the + * stale/missing path, so the common warm-cache snapshot pays nothing. + * + * v1.15.20 — two honesty upgrades over the old tri-state: + * - a stale-but-parseable briefing is DELIVERED with + * `briefingStale: true` (plus its timestamp) instead of `null`, so + * the tile can show yesterday's content while the warm pass runs; + * - when no provider is configured anywhere the state is + * `"no-provider"` instead of an eternal `"preparing"`, so clients + * can point at Settings → AI rather than spin forever. */ -function liftBriefing( +async function liftBriefing( user: SnapshotUserInput, coachEnabled: boolean, -): { + hasProvider: () => Promise, +): Promise<{ briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; -} { + briefingStale: boolean; +}> { if (!coachEnabled || user.disableCoach) { return { briefing: null, briefingState: "disabled", briefingUpdatedAt: null, + briefingStale: false, }; } @@ -455,47 +504,30 @@ function liftBriefing( const updatedAt = cachedAt?.toISOString() ?? null; const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS; - if (stale || !user.insightsCachedText) { + const cachedBriefing = parseCachedBriefing(user.insightsCachedText); + + if (!stale && cachedBriefing) { return { - briefing: null, - briefingState: "preparing", + briefing: cachedBriefing, + briefingState: "ready", briefingUpdatedAt: updatedAt, + briefingStale: false, }; } - try { - const parsed = JSON.parse(user.insightsCachedText) as Record< - string, - unknown - >; - const candidate = parsed?.dailyBriefing; - if (candidate == null) { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } - const validated = dailyBriefingSchema.safeParse(candidate); - if (!validated.success) { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } - return { - briefing: validated.data, - briefingState: "ready", - briefingUpdatedAt: updatedAt, - }; - } catch { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } + // Stale or unusable cache — distinguish "a warm pass will fill this" + // from "nothing ever will" so the client can stop waiting honestly. + const briefingState: BriefingState = (await hasProvider()) + ? "preparing" + : "no-provider"; + return { + // Serve the last good briefing (when one parses) instead of a blank + // tile; `briefingStale: true` + the timestamp carry the honesty. + briefing: cachedBriefing, + briefingState, + briefingUpdatedAt: updatedAt, + briefingStale: cachedBriefing !== null, + }; } /** @@ -592,6 +624,12 @@ export async function buildDashboardSnapshot( options: { /** Optional per-sub-query timing sink (route surfaces it under `meta`). */ time?: (label: string, fn: () => Promise) => Promise; + /** + * Provider credential-presence probe for the briefing lift — + * injectable for tests. Defaults to the cheap presence check in + * `@/lib/ai/provider` (no decrypt, no network). + */ + hasProvider?: () => Promise; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -620,7 +658,11 @@ export async function buildDashboardSnapshot( ]); const layout = resolveDashboardLayout(user.dashboardWidgetsJson); - const briefing = liftBriefing(user, flags.briefing); + const briefing = await liftBriefing( + user, + flags.briefing, + options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), + ); const gender = user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; @@ -651,6 +693,7 @@ export async function buildDashboardSnapshot( briefing: briefing.briefing, briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, + briefingStale: briefing.briefingStale, generatedAt: now.toISOString(), }; } diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index b09bad953..8dca9415a 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -87,6 +87,7 @@ function buildUser(disableCoach: boolean): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference: "metric", + timeFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts new file mode 100644 index 000000000..8e2778214 --- /dev/null +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -0,0 +1,50 @@ +/** + * v1.15.20 — soft-deleted rows must never reach an AI prompt snapshot. + * + * The user-facing DELETE flips `deletedAt` instead of removing the row, + * so every prompt-feeding read has to filter the tombstones explicitly. + * Three status generators (general / weight / bmi) and the Coach + * snapshot's intake read shipped without the filter; this guard pins + * the `deletedAt: null` predicate at each of those call sites so a + * future query rewrite cannot silently drop it (same source-guard + * pattern the queue-registration tests use). + */ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +function read(rel: string): string { + return fs.readFileSync(path.resolve(__dirname, rel), "utf8"); +} + +describe("prompt reads exclude tombstoned rows", () => { + it("general-status filters its measurement read", () => { + const src = read("../general-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("weight-status filters its measurement read", () => { + const src = read("../weight-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("bmi-status filters its measurement read", () => { + const src = read("../bmi-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("the Coach snapshot filters its intake read", () => { + const src = read("../../ai/coach/snapshot.ts"); + const intakeRead = src.match( + /medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/, + ); + expect(intakeRead).not.toBeNull(); + expect(intakeRead![0]).toContain("deletedAt: null"); + }); +}); diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index d30ee3398..f30555b77 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -128,6 +128,8 @@ export async function generateBmiStatusForUser( where: { userId, type: "WEIGHT", + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, orderBy: { measuredAt: "desc" }, take: 365, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5e429520b..c8174869a 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -10,6 +10,7 @@ import { buildComplianceMedicationContext, calculateCompliance, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { @@ -911,7 +912,9 @@ export async function extractFeatures( // Medications const medications = await prisma.medication.findMany({ where: { userId, active: true }, - include: { schedules: true }, + // v1.15.20 — schedules through the shared compliance select so the + // configured per-dose windows reach this surface like every other. + include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } }, }); if (medications.length > 0) { diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 98d06da5c..a0c325324 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -132,6 +132,8 @@ export async function generateGeneralStatusForUser( type: { in: [...MEASUREMENT_TYPES], }, + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, orderBy: { measuredAt: "desc" }, take: 5000, diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index f10bbdd25..04599c6d1 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -32,11 +32,16 @@ export function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } -/** Narrow an arbitrary locale string to the two the prompts support. */ +/** + * Narrow an arbitrary locale string to the two the prompts support. + * Non-German locales (fr/es/it/pl, unknown values) resolve to ENGLISH — + * the same routing the no-key fallbacks use — so a French UI never + * receives German AI prose just because the prompts ship DE + EN only. + */ export function normalizeLocale( value: string | null | undefined, ): SupportedLocale { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } export interface SeriesSummary { diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 59b69544a..14c9faca4 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -172,6 +172,8 @@ export async function generateWeightStatusForUser( type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], }, + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, take: 1095, orderBy: { measuredAt: "desc" }, diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index ee4cdca32..3360a6789 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -212,9 +212,10 @@ describe("runInsightPregenerate — outcome tally", () => { failed: 1, budgetBlocked: 0, }); - // Locale defaulting: null → "de". + // Locale defaulting: v1.15.20 — non-German (incl. null) → "en", + // matching the no-key fallback routing. expect(generate).toHaveBeenLastCalledWith("d", { - locale: "de", + locale: "en", force: true, }); }); @@ -662,9 +663,9 @@ describe("queue registration", () => { expect(match![1]).toMatch(/\bINSIGHT_PREGENERATE_QUEUE\b/); }); - it("schedules the cron in the schedules table", () => { + it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*\]/, + /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*insightRetryOptions\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts index 3a2d99c0c..05ccc4bb3 100644 --- a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts +++ b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts @@ -62,8 +62,11 @@ describe("reminder-worker — intake-slot-dedup wiring", () => { }); it("registers the cron in the schedules table", () => { + // v1.15.20 — the schedules tuple grew an optional send-options slot + // (retry policy for the insight queues); match on the assignment, not + // the exact tuple type. const schedulesMatch = source.match( - /const schedules:\s*\[string, string\]\[\]\s*=\s*\[([\s\S]*?)\n \];/, + /const schedules:[^=]*=\s*\[([\s\S]*?)\n \];/, ); expect(schedulesMatch).not.toBeNull(); expect(schedulesMatch![1]).toMatch( diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 52eb46354..6f507f8fa 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -25,9 +25,22 @@ import { PERIOD_NARRATIVE_CRON, } from "../period-narrative-warm"; -function makePrisma(users: Array<{ id: string; locale: string | null }>) { +function makePrisma( + users: Array<{ id: string; locale: string | null }>, + narrativeWrites: Array<{ userId: string; updatedAt: Date }> = [], +) { const findMany = vi.fn().mockResolvedValue(users); - return { prisma: { user: { findMany } }, findMany }; + const groupBy = vi.fn().mockResolvedValue( + narrativeWrites.map((row) => ({ + userId: row.userId, + _max: { updatedAt: row.updatedAt }, + })), + ); + return { + prisma: { user: { findMany }, insightNarrative: { groupBy } }, + findMany, + groupBy, + }; } // A Monday that is also the 1st of the month — both periods warm. @@ -61,13 +74,37 @@ describe("periodsForDay — boundary gate", () => { }); describe("findNarrativeCandidates", () => { - it("filters coach-enabled users, capped", async () => { - const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + it("filters coach-enabled users", async () => { + const { prisma, findMany, groupBy } = makePrisma([ + { id: "u1", locale: "de" }, + ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await findNarrativeCandidates(prisma as any, 50); + const result = await findNarrativeCandidates(prisma as any, 50); const arg = findMany.mock.calls[0][0]; expect(arg.where.disableCoach).toBe(false); - expect(arg.take).toBe(50); + expect(result).toEqual([{ id: "u1", locale: "de" }]); + // Below the cap the ordering aggregate never runs. + expect(groupBy).not.toHaveBeenCalled(); + }); + + it("serves never-warmed and oldest-narrative users first when the cap bites", async () => { + const { prisma, groupBy } = makePrisma( + [ + { id: "fresh", locale: "de" }, + { id: "stale", locale: "de" }, + { id: "never", locale: "de" }, + ], + [ + { userId: "fresh", updatedAt: new Date("2026-06-09T05:00:00Z") }, + { userId: "stale", updatedAt: new Date("2026-05-01T05:00:00Z") }, + ], + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findNarrativeCandidates(prisma as any, 2); + expect(groupBy).toHaveBeenCalledTimes(1); + // never-warmed first (no narrative row), then the staleest write; + // the freshest user falls past the cap. + expect(result.map((c) => c.id)).toEqual(["never", "stale"]); }); }); @@ -150,9 +187,9 @@ describe("queue registration", () => { expect(match![1]).toMatch(/\bPERIOD_NARRATIVE_QUEUE\b/); }); - it("schedules the cron in the schedules table", () => { + it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*\]/, + /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*insightRetryOptions\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/report-worker-error.test.ts b/src/lib/jobs/__tests__/report-worker-error.test.ts new file mode 100644 index 000000000..4d887754a --- /dev/null +++ b/src/lib/jobs/__tests__/report-worker-error.test.ts @@ -0,0 +1,81 @@ +/** + * v1.15.20 — unit tests for the central worker-error reporter. + * + * Pins the contract: stderr + GlitchTip with the api-handler redaction, + * disabled-settings short-circuit, and the never-throws guarantee. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getGlitchtipSettings = vi.fn(); +const sendGlitchtipEvent = vi.fn(); + +vi.mock("@/lib/monitoring-settings", () => ({ + getGlitchtipSettings: (...a: unknown[]) => getGlitchtipSettings(...a), +})); +vi.mock("@/lib/monitoring/glitchtip", () => ({ + sendGlitchtipEvent: (...a: unknown[]) => sendGlitchtipEvent(...a), +})); + +import { reportWorkerError } from "../report-worker-error"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + getGlitchtipSettings.mockResolvedValue({ + glitchtipEnabled: true, + glitchtipDsn: "https://key@glitchtip.example/1", + glitchtipEnvironment: "test", + }); + sendGlitchtipEvent.mockResolvedValue({ ok: true }); +}); + +describe("reportWorkerError", () => { + it("forwards queue-tagged, redacted errors to GlitchTip", async () => { + await reportWorkerError( + "insight-pregenerate", + new Error("provider call failed"), + { mode: "scheduled" }, + ); + expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1); + const { dsn, input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(dsn).toBe("https://key@glitchtip.example/1"); + expect(input.message).toBe( + "[insight-pregenerate] provider call failed (mode=scheduled)", + ); + expect(input.level).toBe("error"); + expect(input.sourceTag).toBe("healthlog-worker"); + expect(input.environment).toBe("test"); + }); + + it("redacts secret-shaped material from the message", async () => { + await reportWorkerError( + "insight-status-generate", + new Error("upstream rejected Bearer sk-abcdef1234567890abcdef"), + ); + const { input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(input.message).not.toContain("sk-abcdef1234567890abcdef"); + }); + + it("still writes stderr when GlitchTip is disabled", async () => { + getGlitchtipSettings.mockResolvedValue({ + glitchtipEnabled: false, + glitchtipDsn: null, + }); + await reportWorkerError("drain-cumulative", new Error("boom")); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + + it("never throws — a sink failure cannot mask the handler error", async () => { + sendGlitchtipEvent.mockRejectedValue(new Error("glitchtip down")); + await expect( + reportWorkerError("period-narrative-warm", new Error("boom")), + ).resolves.toBeUndefined(); + }); + + it("wraps non-Error inputs", async () => { + await reportWorkerError("mean-consolidation", "string failure"); + const { input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(input.message).toBe("[mean-consolidation] string failure"); + }); +}); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts new file mode 100644 index 000000000..b694cfc94 --- /dev/null +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -0,0 +1,212 @@ +/** + * v1.15.20 — unit tests for the shared 02:xx status-cron discovery. + * + * Pins the three gates the discovery applies: + * 1. the operator assistant kill-switch (`insightStatus` flag), + * 2. the per-user `disableCoach: false` filter, + * 3. the pregenerate-candidate skip (configured provider + stale + * comprehensive cache → the 04:30 pass owns the user; the 02:xx + * crons keep fresh-cache and provider-less accounts), + * plus the mood-status queue registration in the worker source (the + * v1.4.37 dead-queue class guard). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const getAssistantFlags = vi.fn(); +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), +})); + +import { findStatusCronCandidates } from "../status-cron-candidates"; +import { PREGENERATE_STALE_MS } from "../insight-pregenerate"; + +const NOW = new Date("2026-06-10T02:00:00.000Z"); +const STALE_AT = new Date(NOW.getTime() - PREGENERATE_STALE_MS - 60_000); +const FRESH_AT = new Date(NOW.getTime() - 60_000); + +interface FakeUserRow { + id: string; + locale: string | null; + insightsCachedAt: Date | null; + aiProvider: string | null; + aiProviderChain: unknown; + aiAnthropicKeyEncrypted: string | null; + aiLocalKeyEncrypted: string | null; + aiOpenaiKeyEncrypted: string | null; + aiBaseUrl: string | null; + codexConnectionStatus: string | null; + codexAccessTokenEncrypted: string | null; + codexRefreshTokenEncrypted: string | null; +} + +function userRow(overrides: Partial = {}): FakeUserRow { + return { + id: "u1", + locale: "de", + insightsCachedAt: null, + aiProvider: null, + aiProviderChain: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiBaseUrl: null, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + ...overrides, + }; +} + +function makePrisma( + users: FakeUserRow[], + adminAiKeyEncrypted: string | null = null, +) { + const findMany = vi.fn().mockResolvedValue(users); + const findUnique = vi.fn().mockResolvedValue({ adminAiKeyEncrypted }); + return { + prisma: { + user: { findMany }, + appSettings: { findUnique }, + }, + findMany, + findUnique, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + getAssistantFlags.mockResolvedValue({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + }); +}); + +describe("findStatusCronCandidates — gates", () => { + it("returns nothing when the insightStatus surface is disabled (operator kill-switch)", async () => { + getAssistantFlags.mockResolvedValue({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + }); + const { prisma, findMany } = makePrisma([userRow()]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("filters discovery to disableCoach: false", async () => { + const { prisma, findMany } = makePrisma([userRow()]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findStatusCronCandidates(prisma as any, NOW); + expect(findMany.mock.calls[0][0].where).toEqual({ disableCoach: false }); + }); + + it("skips a pregenerate candidate (configured provider + stale cache)", async () => { + const { prisma } = makePrisma([ + userRow({ + id: "stale-with-provider", + insightsCachedAt: STALE_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps a provider user whose comprehensive cache is still fresh", async () => { + const { prisma } = makePrisma([ + userRow({ + id: "fresh-with-provider", + insightsCachedAt: FRESH_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "fresh-with-provider", locale: "de" }]); + }); + + it("keeps a provider-less user even with a stale cache (no pregenerate claim)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "no-provider", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "no-provider", locale: "de" }]); + }); + + it("counts the operator's shared admin key as a configured provider", async () => { + const { prisma } = makePrisma( + [userRow({ id: "admin-covered", insightsCachedAt: STALE_AT })], + "admin-enc", + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps every user when the briefing surface is off (the 04:30 pass no-ops)", async () => { + getAssistantFlags.mockResolvedValue({ + enabled: true, + coach: true, + briefing: false, + insightStatus: true, + }); + const { prisma, findUnique } = makePrisma([ + userRow({ + id: "stale-with-provider", + insightsCachedAt: STALE_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "stale-with-provider", locale: "de" }]); + // No admin-key read needed when the skip never applies. + expect(findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("mood-status queue registration (dead-queue guard)", () => { + const workerSrc = fs.readFileSync( + path.resolve(__dirname, "../reminder-worker.ts"), + "utf8", + ); + + it("registers the queue in the allQueues createQueue loop", () => { + const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(match).not.toBeNull(); + expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/); + }); + + it("schedules the 02:30 cron with the insight retry policy", () => { + expect(workerSrc).toMatch( + /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/, + ); + expect(workerSrc).toMatch( + /MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSrc).toMatch(/boss\.work[\s\S]{0,120}MOOD_STATUS_QUEUE/); + }); + + it("drives all seven status crons through the shared discovery", () => { + expect(workerSrc).toMatch(/findStatusCronCandidates\(prisma\)/); + // The old iterate-every-user discovery must be gone from the status + // handlers (the WHOOP/data-backup cohort reads keep their own scans). + const statusBlock = workerSrc.slice( + workerSrc.indexOf("async function runStatusCronGenerate"), + workerSrc.indexOf("async function handleMoodLogSync"), + ); + expect(statusBlock).not.toMatch(/prisma\.user\.findMany/); + }); +}); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 564b0ec09..2d4e5d1a8 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -67,6 +67,11 @@ export async function enqueueForceWarm(payload: { // De-dupe within a 2-minute window so a double tap or an // auto-trigger racing a manual tap enqueues one warm, not two. singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead + // of leaving every cache cold until the nightly cron. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }, ); annotate({ diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 54e4d137c..39e08f824 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -337,8 +337,13 @@ export async function findPregenerateCandidates( }); } +/** + * Non-German locales resolve to ENGLISH (matching + * `status-shared.normalizeLocale` and the no-key fallback routing) so a + * fr/es/it/pl account gets English AI prose, not German. + */ function normalizeLocale(value: string | null): "de" | "en" { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } /** diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 42ac9c741..0811e46d2 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -107,6 +107,11 @@ export async function enqueueStatusGeneration( // De-dupe within a short window so a polling client doesn't enqueue // a fresh job on every tick while a generation is already running. singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead of + // leaving the card on "preparing" until the next nightly cron. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }); annotate({ action: { name: "insights.status.generate.enqueued" }, diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 47ffc0e44..c6829e148 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -62,6 +62,11 @@ export async function enqueueNarrativeWarm(payload: { { singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`, singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead + // of leaving the narrative cold until the next boundary night. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }, ); annotate({ diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 067f2d266..cbcaa48da 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -96,20 +96,47 @@ interface NarrativeCandidate { * a provider / enough history is confirmed inside the generator (skipped / * insufficient), so a provider-less or sparse account costs at most one cheap * chain-resolve and no LLM call. + * + * Ordering needs a second read: `InsightNarrative` keeps one row per + * (user, period, locale), so the user's most recent narrative write is a + * per-user `MAX(updated_at)` — never-warmed users (no rows at all) sort + * first, then the staleest. The aggregate only runs when the cohort + * actually exceeds the cap; below it every candidate is processed anyway + * and the order is irrelevant. */ export async function findNarrativeCandidates( prisma: PrismaClient, cap: number, ): Promise { - return prisma.user.findMany({ + const users: NarrativeCandidate[] = await prisma.user.findMany({ where: { disableCoach: false }, - take: cap, select: { id: true, locale: true }, }); + if (users.length <= cap) return users; + + const latest = await prisma.insightNarrative.groupBy({ + by: ["userId"], + _max: { updatedAt: true }, + }); + const latestByUser = new Map( + latest.map((row) => [ + row.userId, + row._max.updatedAt?.getTime() ?? 0, + ]), + ); + return [...users] + .sort( + (a, b) => (latestByUser.get(a.id) ?? 0) - (latestByUser.get(b.id) ?? 0), + ) + .slice(0, cap); } +/** + * Non-German locales resolve to ENGLISH (matching + * `status-shared.normalizeLocale` and the no-key fallback routing). + */ function normalizeLocale(value: string | null): "de" | "en" { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } /** diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index c59be013c..c277f8ff8 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -50,7 +50,10 @@ import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressur import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { markWorkerStarted, recordReminderCheck, @@ -281,6 +284,11 @@ const PULSE_STATUS_QUEUE = "insights-pulse-status"; const PULSE_STATUS_CRON = "15 2 * * *"; // daily at 02:15 const BMI_STATUS_QUEUE = "insights-bmi-status"; const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20 +// v1.15.20 — mood joins the nightly per-metric status ladder. Same gate + +// discovery as the six older crons (see status-cron-candidates.ts); 02:30 +// continues the 5-minute stagger after BMI (02:20) and compliance (02:25). +const MOOD_STATUS_QUEUE = "insights-mood-status"; +const MOOD_STATUS_CRON = "30 2 * * *"; // daily at 02:30 const MEDICATION_COMPLIANCE_STATUS_QUEUE = "insights-medication-compliance-status"; const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25 @@ -470,6 +478,10 @@ interface BmiStatusPayload { triggeredAt: string; } +interface MoodStatusPayload { + triggeredAt?: string; +} + interface MedicationComplianceStatusPayload { triggeredAt: string; } @@ -1252,15 +1264,30 @@ function handleWhoopCycleSync(jobs: Job[]) { return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle); } -async function handleGeneralStatusGenerate(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.insights.general", async (evt) => { +/** + * Shared driver for the nightly 02:xx per-metric status crons. User + * discovery is centralised in `findStatusCronCandidates`, which applies + * the operator assistant kill-switch, the per-user `disableCoach` gate, + * and the pregenerate-candidate skip (users with a configured provider + * and a stale comprehensive cache belong to the 04:30 pre-generate pass, + * which re-warms every per-status cache anyway — see + * `status-cron-candidates.ts` for the full division of nightly labour). + * The generators normalise `locale` themselves (de stays de, everything + * else gets English prose). + */ +async function runStatusCronGenerate( + taskName: string, + generate: ( + userId: string, + options: { locale: string | null; force: boolean }, + ) => Promise, + options: { recordRun?: boolean } = {}, +): Promise { + await withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); try { - recordInsightsRun(); - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + if (options.recordRun) recordInsightsRun(); + const users = await findStatusCronCandidates(prisma); if (users.length === 0) return; @@ -1269,240 +1296,81 @@ async function handleGeneralStatusGenerate(jobs: Job[]) { for (const user of users) { try { - await generateGeneralStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); + await generate(user.id, { locale: user.locale, force: false }); generated++; } catch (error) { failed++; evt.addWarning( - `general-status generation failed for user ${user.id}: ${error}`, + `${taskName} generation failed for user ${user.id}: ${error}`, ); } } evt.setBackground({ - task_name: "job.insights.general", + task_name: taskName, result: { generated, failed, total: users.length }, }); } catch (err) { evt.setError(err); recordError(); + await reportWorkerError(taskName, err); throw err; } }); } -async function handleBloodPressureStatusGenerate( +function handleGeneralStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate( + "job.insights.general", + generateGeneralStatusForUser, + { recordRun: true }, + ); +} + +function handleBloodPressureStatusGenerate( jobs: Job[], ) { void jobs; - await withBackgroundEvent("job.insights.blood_pressure", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateBloodPressureStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `blood-pressure-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.blood_pressure", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.blood_pressure", + generateBloodPressureStatusForUser, + ); } -async function handleWeightStatusGenerate(jobs: Job[]) { +function handleWeightStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.weight", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateWeightStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `weight-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.weight", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.weight", + generateWeightStatusForUser, + ); } -async function handlePulseStatusGenerate(jobs: Job[]) { +function handlePulseStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.pulse", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generatePulseStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `pulse-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.pulse", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.pulse", + generatePulseStatusForUser, + ); } -async function handleBmiStatusGenerate(jobs: Job[]) { +function handleBmiStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.bmi", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateBmiStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `bmi-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.bmi", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); } -async function handleMedicationComplianceStatusGenerate( +function handleMoodStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); +} + +function handleMedicationComplianceStatusGenerate( jobs: Job[], ) { void jobs; - await withBackgroundEvent( + return runStatusCronGenerate( "job.insights.medication_compliance", - async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateMedicationComplianceStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `medication-compliance-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.medication_compliance", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }, + generateMedicationComplianceStatusForUser, ); } @@ -2066,6 +1934,13 @@ async function handleInsightPregenerateJob( err instanceof Error ? err.message : String(err) }`, ); + // Surface the failure centrally and rethrow so the queue's retry + // policy (retryLimit 3 + backoff) re-runs the warm — swallowing it + // here left the user's caches cold with zero operator signal. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "force-warm", + }); + throw err; } } @@ -2082,6 +1957,12 @@ async function handleInsightPregenerateJob( err instanceof Error ? err.message : String(err) }`, ); + // Same contract as the force path: report + rethrow so the nightly + // tick retries instead of silently waiting for the next night. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "scheduled", + }); + throw err; } }); } @@ -2107,6 +1988,13 @@ async function handleInsightStatusGenerate( err instanceof Error ? err.message : String(err) }`, ); + // Report centrally + rethrow so the enqueue's retry policy + // (retryLimit 3 + backoff) re-runs the generation; the polling + // client otherwise sits on "preparing" with zero operator signal. + await reportWorkerError(INSIGHT_STATUS_GENERATE_QUEUE, err, { + metric: job.data.metric, + }); + throw err; } } }); @@ -2386,6 +2274,10 @@ export async function startReminderWorker() { WEIGHT_STATUS_QUEUE, PULSE_STATUS_QUEUE, BMI_STATUS_QUEUE, + // v1.15.20 — mood joins the nightly status ladder. The queue MUST be + // registered here or pg-boss never provisions it and the 02:30 schedule + // silently no-ops (the v1.4.37 dead-queue class). + MOOD_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_QUEUE, MOODLOG_SYNC_QUEUE, DATA_BACKUP_QUEUE, @@ -2575,18 +2467,39 @@ export async function startReminderWorker() { workerLog("error", "integration-status-null-probe failed", err); } - // Schedule recurring cron jobs - const schedules: [string, string][] = [ + // v1.15.20 — retry policy for the LLM-bound insight queues. A transient + // failure (provider hiccup, pool exhaustion) used to fail the nightly tick + // silently until the NEXT night; three backed-off retries match the + // backfill queues' established shape (see e.g. whoop-backfill.ts). + const insightRetryOptions = { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + } as const; + + // Schedule recurring cron jobs. The optional third element carries + // per-queue send options (retry policy) merged into the schedule call. + const schedules: [string, string, Record?][] = [ [QUEUE_NAME, CHECK_INTERVAL_CRON], [WITHINGS_SYNC_QUEUE, WITHINGS_SYNC_CRON], [WITHINGS_ACTIVITY_QUEUE, WITHINGS_ACTIVITY_CRON], [WITHINGS_SLEEP_QUEUE, WITHINGS_SLEEP_CRON], - [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON], - [BLOOD_PRESSURE_STATUS_QUEUE, BLOOD_PRESSURE_STATUS_CRON], - [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON], - [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON], - [BMI_STATUS_QUEUE, BMI_STATUS_CRON], - [MEDICATION_COMPLIANCE_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_CRON], + [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions], + [ + BLOOD_PRESSURE_STATUS_QUEUE, + BLOOD_PRESSURE_STATUS_CRON, + insightRetryOptions, + ], + [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions], + [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions], + [BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions], + // v1.15.20 — mood status nightly, continuing the 02:xx ladder. + [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions], + [ + MEDICATION_COMPLIANCE_STATUS_QUEUE, + MEDICATION_COMPLIANCE_STATUS_CRON, + insightRetryOptions, + ], [MOODLOG_SYNC_QUEUE, MOODLOG_SYNC_CRON], [DATA_BACKUP_QUEUE, DATA_BACKUP_CRON], [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], @@ -2653,7 +2566,7 @@ export async function startReminderWorker() { [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight // pre-generation. Budget-gated per user inside the handler. - [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON], + [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions], // v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement // tombstones. [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], @@ -2678,11 +2591,14 @@ export async function startReminderWorker() { // v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The // handler only fans out on a week (Mon) / month (1st) boundary; every // other night is a cheap no-op. Budget-gated per user inside the runner. - [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON], + [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions], ]; - for (const [name, cron] of schedules) { - await boss.schedule(name, cron, {}, { tz: "Europe/Berlin" }); + for (const [name, cron, sendOptions] of schedules) { + await boss.schedule(name, cron, {}, { + tz: "Europe/Berlin", + ...(sendOptions ?? {}), + }); } // Register the handler @@ -2805,6 +2721,11 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleBmiStatusGenerate, ); + await boss.work( + MOOD_STATUS_QUEUE, + { localConcurrency: 1 }, + handleMoodStatusGenerate, + ); await boss.work( MEDICATION_COMPLIANCE_STATUS_QUEUE, { localConcurrency: 1 }, @@ -3010,7 +2931,9 @@ export async function startReminderWorker() { } } catch (err) { recordError(); - workerLog("error", "[period-narrative] warm failed", err); + await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { + mode: job.data?.userId ? "single-user" : "scheduled", + }); throw err; } } @@ -3339,7 +3262,7 @@ export async function startReminderWorker() { ); } catch (err) { recordError(); - workerLog("error", "[drain-cumulative] run failed", err); + await reportWorkerError("drain-cumulative", err); } // v1.8.5 — fold the daily-MEAN drain onto the same nightly tick as @@ -3365,7 +3288,9 @@ export async function startReminderWorker() { ); } catch (err) { recordError(); - workerLog("error", "[mean-consolidation] nightly run failed", err); + await reportWorkerError("mean-consolidation", err, { + tick: "nightly", + }); } // v1.10.0 WX-E — fold the dense intra-day retention drain onto the @@ -3400,11 +3325,9 @@ export async function startReminderWorker() { } } catch (err) { recordError(); - workerLog( - "error", - "[dense-intraday-retention] nightly run failed", - err, - ); + await reportWorkerError("dense-intraday-retention", err, { + tick: "nightly", + }); } } }, diff --git a/src/lib/jobs/report-worker-error.ts b/src/lib/jobs/report-worker-error.ts new file mode 100644 index 000000000..f10e9fc75 --- /dev/null +++ b/src/lib/jobs/report-worker-error.ts @@ -0,0 +1,76 @@ +/** + * Central error reporter for pg-boss worker handlers. + * + * Worker errors used to be visible only as a stderr line (`workerLog`) + * — they never reached GlitchTip, so a nightly pass that failed for + * weeks was invisible unless the operator happened to read container + * logs. This helper gives every worker catch the same two sinks the + * HTTP layer has: + * + * 1. stderr (the operator's container log), and + * 2. GlitchTip, through the same `sendGlitchtipEvent` payload shape + * `api-handler.ts` uses, with the same redaction + * (`redactSecrets` on the message, `redactOptional` on the stack) + * so a Telegram bot token or Bearer credential embedded in an + * error message cannot leak into the incident UI. + * + * Fire-and-forget by contract: the reporter NEVER throws, so a sink + * failure (GlitchTip down, settings read failing) cannot mask or + * replace the original handler error. + */ +import { redactOptional, redactSecrets } from "@/lib/logging/redact"; + +/** + * Report one worker error. `queue` is the pg-boss queue (or logical + * pass) name; `meta` is a small pinned-shape key/value bag folded into + * the message tail (GlitchTip's event payload has no free-form extras + * field) — keep values to identifiers and counts, never payload data. + */ +export async function reportWorkerError( + queue: string, + error: unknown, + meta: Record = {}, +): Promise { + const err = + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : "Unknown worker error"); + + const metaEntries = Object.entries(meta); + const metaSuffix = + metaEntries.length > 0 + ? ` (${metaEntries.map(([k, v]) => `${k}=${v}`).join(" ")})` + : ""; + const message = redactSecrets(`[${queue}] ${err.message}${metaSuffix}`); + + // Sink 1 — stderr, mirroring `workerLog("error", …)` so the operator + // keeps the container-log trail even when GlitchTip is disabled. + console.error(`[pg-boss] ${message}`, err); + + // Sink 2 — GlitchTip, fire-and-forget. Dynamic imports mirror the + // api-handler forwarder (no startup cost, no import cycle). + try { + const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = + await Promise.all([ + import("@/lib/monitoring-settings"), + import("@/lib/monitoring/glitchtip"), + ]); + const settings = await getGlitchtipSettings(); + if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return; + + await sendGlitchtipEvent({ + dsn: settings.glitchtipDsn, + input: { + environment: settings.glitchtipEnvironment || "production", + message, + level: "error", + type: err.name || "Error", + stack: redactOptional(err.stack), + sourceTag: "healthlog-worker", + }, + }); + } catch { + // The reporter must never throw — a sink failure would otherwise + // mask the original handler error. + } +} diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts new file mode 100644 index 000000000..17d5c53b3 --- /dev/null +++ b/src/lib/jobs/status-cron-candidates.ts @@ -0,0 +1,98 @@ +/** + * Shared user discovery for the nightly 02:xx per-metric status crons + * (general / blood-pressure / weight / pulse / bmi / medication- + * compliance / mood). + * + * Before this module each cron iterated EVERY user row: accounts that + * disabled the coach surface (`disableCoach`) and operator-disabled + * deployments (assistant kill-switch) still paid one generator pass per + * user per night, and users the 04:30 `insight-pregenerate` cron was + * going to re-warm anyway were generated twice. + * + * Division of nightly labour (documented here once, referenced by the + * handlers): + * + * - 02:xx status crons → users WITHOUT a comprehensive-pregenerate + * claim: no configured provider, or a comprehensive cache fresh + * enough (< 20 h) that the 04:30 pass will skip them — their + * per-status caches still age out daily and only these crons + * re-fill them. + * - 04:30 insight-pregenerate → the rest: coach-enabled users with a + * configured provider and a stale (>= 20 h) comprehensive cache. + * That pass regenerates the comprehensive insight AND force-warms + * every per-status cache, so a 02:xx generation for those users + * would be evicted and redone two hours later. + * + * Gates, in order: + * 1. Operator kill-switch via `getAssistantFlags()` — the same source + * `insight-pregenerate` uses. The status cards belong to the + * `insightStatus` surface, so that flag (which the master assistant + * switch also forces off) gates the whole pass. + * 2. Per-user `disableCoach: false` — a user who disabled the AI + * surface gets no nightly generation. + * 3. The pregenerate-candidate skip described above (only when the + * `briefing` surface is enabled — when it is off the 04:30 pass + * no-ops, so the 02:xx crons must keep covering everyone). + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { PREGENERATE_STALE_MS } from "@/lib/jobs/insight-pregenerate"; + +export interface StatusCronCandidate { + id: string; + locale: string | null; +} + +export async function findStatusCronCandidates( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const flags = await getAssistantFlags(); + if (!flags.insightStatus) return []; + + const users = await prisma.user.findMany({ + where: { disableCoach: false }, + select: { + id: true, + locale: true, + insightsCachedAt: true, + // Credential-presence columns for the pregenerate-candidate skip — + // evaluated locally, never decrypted. + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + }, + }); + if (users.length === 0) return []; + + // When the briefing surface is off the 04:30 pregenerate pass no-ops, + // so no user has a comprehensive claim and the 02:xx crons keep all. + if (!flags.briefing) { + return users.map((u) => ({ id: u.id, locale: u.locale })); + } + + // One presence read for the operator's shared key covers the cohort. + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; + + const staleBefore = now.getTime() - PREGENERATE_STALE_MS; + return users + .filter((u) => { + const cacheStale = + !u.insightsCachedAt || u.insightsCachedAt.getTime() < staleBefore; + const isPregenerateCandidate = + cacheStale && userRowHasProviderCredential(u, adminKeyConfigured); + return !isPregenerateCandidate; + }) + .map((u) => ({ id: u.id, locale: u.locale })); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 18d216228..a9ac29a3b 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -191,6 +191,12 @@ const profileUpdateRequest = z displayName: z.string().min(1).max(80).nullable().optional(), locale: z.enum(["de", "en"]).nullable().optional(), timezone: z.string().min(1).max(64).optional(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .optional() + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean().optional(), fullName: z.string().max(120).nullable().optional(), insurerName: z.string().max(120).nullable().optional(), @@ -223,6 +229,11 @@ const profileResponse = z heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -253,6 +264,11 @@ const profileUpdateResponse = z heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -1987,14 +2003,19 @@ const dashboardSnapshotResponse = z }) .nullable(), briefing: z.record(z.string(), z.unknown()).nullable(), - briefingState: z.enum(["ready", "preparing", "disabled"]), + briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), briefingUpdatedAt: z.string().nullable(), + briefingStale: z + .boolean() + .describe( + "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + ), generatedAt: z.string(), }) .meta({ id: "DashboardSnapshotResponse", description: - "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", + "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", }); // v1.5.0 — natural-language medication extraction route. The wizard's From aabc7915cbccdab3b050bc6d3002ec9f28fc2b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 13:34:46 +0200 Subject: [PATCH 07/21] feat(settings): a time-format preference and one home for insight pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock format followed the UI language with no way out — an English interface forced AM/PM everywhere. A new profile preference (automatic, 24-hour, 12-hour) feeds every formatter through the shared locale context, persists on the user row and rides the profile endpoints. The insight pill list in settings gains the same eye toggle the overview tiles have, so ordering and visibility live in one place; the page-side 'manage detail pages' block folds into a link there. Includes the locale bundles for both features and the regenerated API contract. --- docs/api/openapi.yaml | 42 ++- messages/de.json | 59 ++-- messages/en.json | 59 ++-- messages/es.json | 59 ++-- messages/fr.json | 59 ++-- messages/it.json | 59 ++-- messages/pl.json | 59 ++-- .../0138_v11520_time_format/migration.sql | 14 + prisma/schema.prisma | 14 + src/app/api/auth/me/route.ts | 3 + .../api/user/profile/__tests__/route.test.ts | 46 +++ src/app/api/user/profile/route.ts | 3 + .../insights-edit-mode-manager.test.tsx | 43 +-- .../insights/insights-edit-mode.tsx | 283 ++---------------- .../insights-pill-order-visibility.test.tsx | 144 +++++++++ .../__tests__/time-format-select.test.tsx | 152 ++++++++++ .../__tests__/unit-preference-select.test.tsx | 1 + src/components/settings/account-section.tsx | 11 +- .../settings/insights-pill-order-section.tsx | 78 ++++- .../settings/time-format-select.tsx | 144 +++++++++ src/hooks/use-auth.ts | 20 ++ src/lib/__tests__/format-locale.test.ts | 51 +++- .../schedule-compliance-select.test.ts | 38 +++ src/lib/auth/profile-update.ts | 6 + src/lib/format-locale.ts | 40 ++- src/lib/format.ts | 6 +- src/lib/i18n/context.tsx | 35 ++- src/lib/time-format.ts | 71 +++++ 28 files changed, 1137 insertions(+), 462 deletions(-) create mode 100644 prisma/migrations/0138_v11520_time_format/migration.sql create mode 100644 src/components/settings/__tests__/insights-pill-order-visibility.test.tsx create mode 100644 src/components/settings/__tests__/time-format-select.test.tsx create mode 100644 src/components/settings/time-format-select.tsx create mode 100644 src/lib/analytics/__tests__/schedule-compliance-select.test.ts create mode 100644 src/lib/time-format.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 85602e835..81b9333fa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4615,6 +4615,13 @@ components: type: string minLength: 1 maxLength: 64 + timeFormat: + description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. + type: string + enum: + - AUTO + - H12 + - H24 moodReminderEnabled: type: boolean fullName: @@ -10022,6 +10029,13 @@ components: - type: "null" timezone: type: string + timeFormat: + type: string + enum: + - AUTO + - H12 + - H24 + description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. moodReminderEnabled: type: boolean fullName: @@ -10050,6 +10064,7 @@ components: - heightCm - locale - timezone + - timeFormat - moodReminderEnabled - fullName - insurerName @@ -10111,6 +10126,13 @@ components: - type: "null" timezone: type: string + timeFormat: + type: string + enum: + - AUTO + - H12 + - H24 + description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. moodReminderEnabled: type: boolean fullName: @@ -10137,6 +10159,7 @@ components: - heightCm - locale - timezone + - timeFormat - moodReminderEnabled - fullName - insurerName @@ -10668,10 +10691,16 @@ components: - ready - preparing - disabled + - no-provider briefingUpdatedAt: anyOf: - type: string - type: "null" + briefingStale: + type: boolean + description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) + or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a + blank tile. generatedAt: type: string required: @@ -10684,15 +10713,18 @@ components: - briefing - briefingState - briefingUpdatedAt + - briefingStale - generatedAt additionalProperties: false - description: Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget + description: "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated - synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full - 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are - additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB - round-trip. + synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` + (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; + stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with + `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per + metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive + in-process from data already fetched, adding no DB round-trip." InsightsComprehensiveResponseEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 4026a4aa4..1bd0337a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1282,19 +1282,6 @@ "paused": "Pausiert", "ended": "Beendet" }, - "today": { - "groupLabel": "Heutige Dosis", - "taken": "Genommen", - "skipped": "Übersprungen", - "toastTaken": "Heutige Dosis erfasst", - "toastSkipped": "Heutige Dosis übersprungen", - "recordedTaken": "Heute genommen um {time}", - "recordedSkipped": "Heute übersprungen um {time}", - "recordedOneShot": "Einmalig genommen um {time}", - "noneScheduled": "Heute keine Einnahme geplant.", - "pausedHint": "Pausiert — heute keine Erinnerung.", - "error": "Dosis konnte nicht erfasst werden." - }, "cadence": { "oneShotOn": "Einmalige Dosis am {date}.", "oneShotPending": "Einmalige Dosis ausstehend." @@ -1349,6 +1336,9 @@ "add": "Eintrag", "markTaken": "Genommen", "markSkipped": "Übersprungen", + "rowActionsLabel": "Aktionen für {time}", + "markAsTaken": "Als genommen markieren", + "markAsSkipped": "Als übersprungen markieren", "adHocTag": "außerplanmäßig", "takenDetail": "geplant {planned} · genommen {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar." }, "grace": { - "label": "Erinnerungsfenster — gilt für deinen Hauptzeitplan", - "primaryScheduleNote": "Andere Zeitpläne behalten ihr Standard-Fenster.", + "label": "Erinnerungsfenster", + "appliesToAllNote": "Gilt für alle Zeitpläne dieses Medikaments.", "unit": "Minuten", "saved": "Erinnerungsfenster gespeichert", "failed": "Erinnerungsfenster konnte nicht gespeichert werden." @@ -1468,7 +1458,6 @@ "title": "Erweiterte Einstellungen", "group": { "data": "Daten", - "reminders": "Erinnerungen", "lifecycle": "Lebenszyklus", "externalApi": "Externe API" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "Einnahmeverlauf ansehen", - "advancedLabel": "Erweiterte Einstellungen" + "historyLabel": "Einnahmeverlauf ansehen" }, "history": { "back": "Zurück zum Medikament", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Übersicht", "zeitplan": "Zeitplan", - "erinnerung": "Erinnerung", "bestand": "Bestand", "verlauf": "Verlauf", "injektion": "Injektion", @@ -1510,6 +1497,17 @@ "tablistLabel": "Medikamenten-Detailbereiche" }, "uebersicht": { + "statusTitle": "Status", + "nextDoseLabel": "Nächste Dosis", + "nextDoseNone": "Keine anstehende Dosis.", + "pausedHint": "Pausiert — keine Erinnerungen.", + "reminderLabel": "Erinnerung", + "reminderOn": "Aktiv", + "reminderOff": "Aus", + "supplyLabel": "Bestand", + "supplyRunway": "Reicht noch ca. {days} Tage", + "jumpToZeitplan": "Zum Zeitplan", + "jumpToBestand": "Zum Bestand", "complianceTitle": "Therapietreue", "complianceEmpty": "Noch keine Daten zur Therapietreue." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Zeitplan {index}", "fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.", "graceEcho": "Erinnerungsfenster: {minutes} Minuten", - "graceLink": "In „Erinnerung“ ändern", + "reminderTitle": "Erinnerung", "save": "Zeitplan speichern", "saved": "Zeitplan gespeichert", "failed": "Zeitplan konnte nicht gespeichert werden", @@ -1712,8 +1710,7 @@ "dragHandle": "Zum Sortieren ziehen", "show": "Einblenden", "hide": "Ausblenden", - "manageSubPages": "Detailseiten verwalten", - "manageSubPagesHint": "Ausgeblendete Seiten verschwinden auch aus der oberen Navigation.", + "manageInSettings": "Pills & Detailseiten in den Einstellungen verwalten", "groupSleep": "Schlaf", "groupMood": "Stimmung", "groupEvents": "Ereignisse", @@ -1843,7 +1840,10 @@ "emptyTitle": "Noch kein Briefing", "emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.", "emptyAction": "Briefing erstellen", - "loadingLabel": "Briefing wird zusammengestellt" + "loadingLabel": "Briefing wird zusammengestellt", + "noProviderTitle": "Kein KI-Anbieter verbunden", + "noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.", + "noProviderAction": "Zu den KI-Einstellungen" }, "trendsRow": { "title": "Trends", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Navigations-Pills sortieren", - "description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.", + "description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus. Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation.", "dragHandle": "Verschieben", "saveSuccess": "Reihenfolge der Pills gespeichert.", "saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.", @@ -3158,6 +3158,8 @@ "empty": "Noch keine Navigations-Pills verfügbar." }, "customize": "Insights anpassen", + "regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.", + "regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.", "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Profil", "language": "Sprache", "languageDescription": "Wähle die Anzeigesprache der Anwendung.", + "timeFormat": { + "title": "Stundenformat", + "description": "Bestimmt, ob Uhrzeiten im 24-Stunden- oder 12-Stunden-Format (AM/PM) angezeigt werden.", + "auto": "Automatisch (Sprache)", + "h24": "24 Stunden", + "h12": "12 Stunden (AM/PM)", + "saved": "Stundenformat gespeichert.", + "saveError": "Speichern des Stundenformats fehlgeschlagen." + }, "username": "Benutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", diff --git a/messages/en.json b/messages/en.json index ac85b895c..12ea7fd8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1282,19 +1282,6 @@ "paused": "Paused", "ended": "Ended" }, - "today": { - "groupLabel": "Today's dose", - "taken": "Taken", - "skipped": "Skipped", - "toastTaken": "Today's dose logged", - "toastSkipped": "Today's dose skipped", - "recordedTaken": "Taken today at {time}", - "recordedSkipped": "Skipped today at {time}", - "recordedOneShot": "One-shot dose taken at {time}", - "noneScheduled": "No dose scheduled for today.", - "pausedHint": "Paused — no reminder today.", - "error": "Could not log the dose." - }, "cadence": { "oneShotOn": "One-time dose on {date}.", "oneShotPending": "One-time dose pending." @@ -1349,6 +1336,9 @@ "add": "Entry", "markTaken": "Taken", "markSkipped": "Skipped", + "rowActionsLabel": "Actions for {time}", + "markAsTaken": "Mark as taken", + "markAsSkipped": "Mark as skipped", "adHocTag": "off-schedule", "takenDetail": "planned {planned} · taken {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Phases are available once a course window is set." }, "grace": { - "label": "Reminder window — applies to your primary schedule", - "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.", + "label": "Reminder window", + "appliesToAllNote": "Applies to every schedule of this medication.", "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." @@ -1468,7 +1458,6 @@ "title": "Advanced settings", "group": { "data": "Data", - "reminders": "Reminders", "lifecycle": "Lifecycle", "externalApi": "External API" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "View intake history", - "advancedLabel": "Advanced settings" + "historyLabel": "View intake history" }, "history": { "back": "Back to medication", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Overview", "zeitplan": "Schedule", - "erinnerung": "Reminders", "bestand": "Supply", "verlauf": "History", "injektion": "Injection", @@ -1510,6 +1497,17 @@ "tablistLabel": "Medication detail sections" }, "uebersicht": { + "statusTitle": "Status", + "nextDoseLabel": "Next dose", + "nextDoseNone": "No upcoming dose.", + "pausedHint": "Paused — no reminders.", + "reminderLabel": "Reminder", + "reminderOn": "On", + "reminderOff": "Off", + "supplyLabel": "Supply", + "supplyRunway": "Lasts about {days} more days", + "jumpToZeitplan": "Open schedule", + "jumpToBestand": "Open supply", "complianceTitle": "Adherence", "complianceEmpty": "No adherence data yet." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Schedule {index}", "fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.", "graceEcho": "Reminder window: {minutes} minutes", - "graceLink": "Change under “Reminders”", + "reminderTitle": "Reminder", "save": "Save schedule", "saved": "Schedule saved", "failed": "Could not save the schedule", @@ -1714,8 +1712,7 @@ "dragHandle": "Drag to reorder", "show": "Show", "hide": "Hide", - "manageSubPages": "Manage detail pages", - "manageSubPagesHint": "Hidden pages are also removed from the top navigation.", + "manageInSettings": "Manage pills & detail pages in Settings", "groupSleep": "Sleep", "groupMood": "Mood", "groupEvents": "Events", @@ -1843,7 +1840,10 @@ "emptyTitle": "No briefing yet", "emptyDescription": "Generate today's briefing to get a narrative read of your trends.", "emptyAction": "Generate briefing", - "loadingLabel": "Synthesising today's briefing" + "loadingLabel": "Synthesising today's briefing", + "noProviderTitle": "No AI provider connected", + "noProviderDescription": "Connect an AI provider in settings to receive daily briefings.", + "noProviderAction": "Open AI settings" }, "trendsRow": { "title": "Trends", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Sort the navigation pills", - "description": "Drag the pills into the order they should appear in along the top of Insights.", + "description": "Drag the pills into the order you want and show or hide them via the eye icon. Hidden detail pages are also removed from the top navigation.", "dragHandle": "Reorder", "saveSuccess": "Pill order saved.", "saveError": "Saving the pill order failed.", @@ -3158,6 +3158,8 @@ "empty": "No navigation pills available yet." }, "customize": "Customize Insights", + "regenerateRateLimited": "Refresh limit reached — please try again later.", + "regenerateUnavailable": "No fresh analysis arrived — please try again later.", "regenerateError": "Still refreshing — please try again in a moment." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Profile", "language": "Language", "languageDescription": "Choose the display language of the application.", + "timeFormat": { + "title": "Hour format", + "description": "Controls whether times render on the 24-hour or the 12-hour (AM/PM) clock.", + "auto": "Automatic (language)", + "h24": "24-hour", + "h12": "12-hour (AM/PM)", + "saved": "Hour format saved.", + "saveError": "Saving the hour format failed." + }, "username": "Username", "height": "Height (cm)", "dateOfBirth": "Date of birth", diff --git a/messages/es.json b/messages/es.json index 07da460d5..173fa8525 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1282,19 +1282,6 @@ "paused": "Pausado", "ended": "Finalizado" }, - "today": { - "groupLabel": "Dosis de hoy", - "taken": "Tomada", - "skipped": "Omitida", - "toastTaken": "Dosis de hoy registrada", - "toastSkipped": "Dosis de hoy omitida", - "recordedTaken": "Tomada hoy a las {time}", - "recordedSkipped": "Omitida hoy a las {time}", - "recordedOneShot": "Dosis única tomada a las {time}", - "noneScheduled": "No hay ninguna dosis programada para hoy.", - "pausedHint": "En pausa — hoy no hay recordatorio.", - "error": "No se pudo registrar la dosis." - }, "cadence": { "oneShotOn": "Dosis única el {date}.", "oneShotPending": "Dosis única pendiente." @@ -1349,6 +1336,9 @@ "add": "Entrada", "markTaken": "Tomada", "markSkipped": "Saltada", + "rowActionsLabel": "Acciones para {time}", + "markAsTaken": "Marcar como tomada", + "markAsSkipped": "Marcar como omitida", "adHocTag": "fuera de horario", "takenDetail": "prevista {planned} · tomada {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Las fases están disponibles una vez fijado un periodo de tratamiento." }, "grace": { - "label": "Ventana de recordatorio — se aplica a tu horario principal", - "primaryScheduleNote": "Los medicamentos con varios horarios mantienen la ventana predeterminada de sus demás horarios.", + "label": "Ventana de recordatorio", + "appliesToAllNote": "Se aplica a todos los horarios de este medicamento.", "unit": "minutos", "saved": "Ventana de recordatorio guardada", "failed": "No se pudo guardar la ventana de recordatorio." @@ -1468,7 +1458,6 @@ "title": "Ajustes avanzados", "group": { "data": "Datos", - "reminders": "Recordatorios", "lifecycle": "Ciclo de vida", "externalApi": "API externa" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "Ver historial de tomas", - "advancedLabel": "Configuración avanzada" + "historyLabel": "Ver historial de tomas" }, "history": { "back": "Volver al medicamento", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Resumen", "zeitplan": "Horario", - "erinnerung": "Recordatorios", "bestand": "Existencias", "verlauf": "Historial", "injektion": "Inyección", @@ -1510,6 +1497,17 @@ "tablistLabel": "Secciones de detalle del medicamento" }, "uebersicht": { + "statusTitle": "Estado", + "nextDoseLabel": "Próxima dosis", + "nextDoseNone": "No hay dosis pendiente.", + "pausedHint": "En pausa — sin recordatorios.", + "reminderLabel": "Recordatorio", + "reminderOn": "Activado", + "reminderOff": "Desactivado", + "supplyLabel": "Existencias", + "supplyRunway": "Alcanza para unos {days} días más", + "jumpToZeitplan": "Ver horario", + "jumpToBestand": "Ver existencias", "complianceTitle": "Adherencia", "complianceEmpty": "Aún no hay datos de adherencia." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Horario {index}", "fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».", "graceEcho": "Ventana de recordatorio: {minutes} minutos", - "graceLink": "Cambiar en «Recordatorios»", + "reminderTitle": "Recordatorio", "save": "Guardar horario", "saved": "Horario guardado", "failed": "No se pudo guardar el horario", @@ -1714,8 +1712,7 @@ "dragHandle": "Arrastra para reordenar", "show": "Mostrar", "hide": "Ocultar", - "manageSubPages": "Gestionar páginas de detalle", - "manageSubPagesHint": "Las páginas ocultas también se quitan de la navegación superior.", + "manageInSettings": "Gestionar pestañas y páginas de detalle en los ajustes", "groupSleep": "Sueño", "groupMood": "Estado de ánimo", "groupEvents": "Eventos", @@ -1843,7 +1840,10 @@ "emptyTitle": "Aún no hay resumen", "emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.", "emptyAction": "Generar resumen", - "loadingLabel": "Preparando el resumen" + "loadingLabel": "Preparando el resumen", + "noProviderTitle": "Ningún proveedor de IA conectado", + "noProviderDescription": "Conecta un proveedor de IA en los ajustes para recibir resúmenes diarios.", + "noProviderAction": "Abrir ajustes de IA" }, "trendsRow": { "title": "Tendencias", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Ordenar las pestañas de navegación", - "description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.", + "description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo. Las páginas de detalle ocultas también se quitan de la navegación superior.", "dragHandle": "Reordenar", "saveSuccess": "Orden de las pestañas guardado.", "saveError": "No se pudo guardar el orden de las pestañas.", @@ -3158,6 +3158,8 @@ "empty": "Aún no hay pestañas de navegación disponibles." }, "customize": "Personalizar Análisis", + "regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.", + "regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.", "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Perfil", "language": "Idioma", "languageDescription": "Elige el idioma de visualización de la aplicación.", + "timeFormat": { + "title": "Formato horario", + "description": "Determina si las horas se muestran en formato de 24 horas o de 12 horas (AM/PM).", + "auto": "Automático (idioma)", + "h24": "24 horas", + "h12": "12 horas (AM/PM)", + "saved": "Formato horario guardado.", + "saveError": "No se pudo guardar el formato horario." + }, "username": "Nombre de usuario", "height": "Altura (cm)", "dateOfBirth": "Fecha de nacimiento", diff --git a/messages/fr.json b/messages/fr.json index d6020b9c1..362d7cd9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1282,19 +1282,6 @@ "paused": "En pause", "ended": "Terminé" }, - "today": { - "groupLabel": "Dose du jour", - "taken": "Pris", - "skipped": "Sauté", - "toastTaken": "Dose du jour enregistrée", - "toastSkipped": "Dose du jour sautée", - "recordedTaken": "Prise aujourd’hui à {time}", - "recordedSkipped": "Sautée aujourd’hui à {time}", - "recordedOneShot": "Dose unique prise à {time}", - "noneScheduled": "Aucune dose prévue aujourd’hui.", - "pausedHint": "En pause — pas de rappel aujourd’hui.", - "error": "Impossible d’enregistrer la dose." - }, "cadence": { "oneShotOn": "Dose unique le {date}.", "oneShotPending": "Dose unique en attente." @@ -1349,6 +1336,9 @@ "add": "Entrée", "markTaken": "Prise", "markSkipped": "Sautée", + "rowActionsLabel": "Actions pour {time}", + "markAsTaken": "Marquer comme prise", + "markAsSkipped": "Marquer comme sautée", "adHocTag": "hors planning", "takenDetail": "prévue {planned} · prise {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Les phases sont disponibles une fois une période de traitement définie." }, "grace": { - "label": "Fenêtre de rappel — s’applique à votre horaire principal", - "primaryScheduleNote": "Les médicaments à horaires multiples conservent la fenêtre par défaut de leurs autres horaires.", + "label": "Fenêtre de rappel", + "appliesToAllNote": "S’applique à tous les horaires de ce médicament.", "unit": "minutes", "saved": "Fenêtre de rappel enregistrée", "failed": "Impossible d’enregistrer la fenêtre de rappel." @@ -1468,7 +1458,6 @@ "title": "Paramètres avancés", "group": { "data": "Données", - "reminders": "Rappels", "lifecycle": "Cycle de vie", "externalApi": "API externe" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "Voir l'historique des prises", - "advancedLabel": "Paramètres avancés" + "historyLabel": "Voir l'historique des prises" }, "history": { "back": "Retour au médicament", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Aperçu", "zeitplan": "Horaire", - "erinnerung": "Rappels", "bestand": "Stock", "verlauf": "Historique", "injektion": "Injection", @@ -1510,6 +1497,17 @@ "tablistLabel": "Sections de détail du médicament" }, "uebersicht": { + "statusTitle": "Statut", + "nextDoseLabel": "Prochaine dose", + "nextDoseNone": "Aucune dose à venir.", + "pausedHint": "En pause — aucun rappel.", + "reminderLabel": "Rappel", + "reminderOn": "Activé", + "reminderOff": "Désactivé", + "supplyLabel": "Stock", + "supplyRunway": "Couvre encore environ {days} jours", + "jumpToZeitplan": "Voir l'horaire", + "jumpToBestand": "Voir le stock", "complianceTitle": "Observance", "complianceEmpty": "Aucune donnée d'observance pour l'instant." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Horaire {index}", "fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».", "graceEcho": "Fenêtre de rappel : {minutes} minutes", - "graceLink": "Modifier dans « Rappels »", + "reminderTitle": "Rappel", "save": "Enregistrer l'horaire", "saved": "Horaire enregistré", "failed": "Impossible d'enregistrer l'horaire", @@ -1714,8 +1712,7 @@ "dragHandle": "Fais glisser pour réorganiser", "show": "Afficher", "hide": "Masquer", - "manageSubPages": "Gérer les pages de détail", - "manageSubPagesHint": "Les pages masquées disparaissent aussi de la navigation supérieure.", + "manageInSettings": "Gérer les onglets et les pages de détail dans les réglages", "groupSleep": "Sommeil", "groupMood": "Humeur", "groupEvents": "Événements", @@ -1843,7 +1840,10 @@ "emptyTitle": "Pas encore de briefing", "emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.", "emptyAction": "Générer le briefing", - "loadingLabel": "Préparation du briefing" + "loadingLabel": "Préparation du briefing", + "noProviderTitle": "Aucun fournisseur d'IA connecté", + "noProviderDescription": "Connecte un fournisseur d'IA dans les réglages pour recevoir des briefings quotidiens.", + "noProviderAction": "Ouvrir les réglages IA" }, "trendsRow": { "title": "Tendances", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Trier les onglets de navigation", - "description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.", + "description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil. Les pages de détail masquées disparaissent aussi de la navigation supérieure.", "dragHandle": "Réorganiser", "saveSuccess": "Ordre des onglets enregistré.", "saveError": "L'enregistrement de l'ordre des onglets a échoué.", @@ -3158,6 +3158,8 @@ "empty": "Aucun onglet de navigation disponible pour le moment." }, "customize": "Personnaliser les Analyses", + "regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.", + "regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.", "regenerateError": "Actualisation en cours — réessayez dans un instant." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Profil", "language": "Langue", "languageDescription": "Choisissez la langue d’affichage de l’application.", + "timeFormat": { + "title": "Format horaire", + "description": "Détermine si les heures s’affichent au format 24 heures ou 12 heures (AM/PM).", + "auto": "Automatique (langue)", + "h24": "24 heures", + "h12": "12 heures (AM/PM)", + "saved": "Format horaire enregistré.", + "saveError": "Échec de l’enregistrement du format horaire." + }, "username": "Nom d'utilisateur", "height": "Taille (cm)", "dateOfBirth": "Date de naissance", diff --git a/messages/it.json b/messages/it.json index 72394ccb9..eb842daf4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1282,19 +1282,6 @@ "paused": "In pausa", "ended": "Terminato" }, - "today": { - "groupLabel": "Dose di oggi", - "taken": "Assunta", - "skipped": "Saltata", - "toastTaken": "Dose di oggi registrata", - "toastSkipped": "Dose di oggi saltata", - "recordedTaken": "Assunta oggi alle {time}", - "recordedSkipped": "Saltata oggi alle {time}", - "recordedOneShot": "Dose singola assunta alle {time}", - "noneScheduled": "Nessuna dose prevista per oggi.", - "pausedHint": "In pausa — nessun promemoria oggi.", - "error": "Impossibile registrare la dose." - }, "cadence": { "oneShotOn": "Dose singola il {date}.", "oneShotPending": "Dose singola in sospeso." @@ -1349,6 +1336,9 @@ "add": "Voce", "markTaken": "Assunta", "markSkipped": "Saltata", + "rowActionsLabel": "Azioni per {time}", + "markAsTaken": "Segna come assunta", + "markAsSkipped": "Segna come saltata", "adHocTag": "fuori programma", "takenDetail": "prevista {planned} · assunta {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Le fasi sono disponibili dopo aver impostato un periodo di trattamento." }, "grace": { - "label": "Finestra di promemoria — si applica al tuo programma principale", - "primaryScheduleNote": "I farmaci con più programmi mantengono la finestra predefinita degli altri programmi.", + "label": "Finestra di promemoria", + "appliesToAllNote": "Vale per tutti i programmi di questo farmaco.", "unit": "minuti", "saved": "Finestra di promemoria salvata", "failed": "Impossibile salvare la finestra di promemoria." @@ -1468,7 +1458,6 @@ "title": "Impostazioni avanzate", "group": { "data": "Dati", - "reminders": "Promemoria", "lifecycle": "Ciclo di vita", "externalApi": "API esterna" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "Visualizza cronologia assunzioni", - "advancedLabel": "Impostazioni avanzate" + "historyLabel": "Visualizza cronologia assunzioni" }, "history": { "back": "Torna al farmaco", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Panoramica", "zeitplan": "Programma", - "erinnerung": "Promemoria", "bestand": "Scorta", "verlauf": "Cronologia", "injektion": "Iniezione", @@ -1510,6 +1497,17 @@ "tablistLabel": "Sezioni di dettaglio del farmaco" }, "uebersicht": { + "statusTitle": "Stato", + "nextDoseLabel": "Prossima dose", + "nextDoseNone": "Nessuna dose in programma.", + "pausedHint": "In pausa — nessun promemoria.", + "reminderLabel": "Promemoria", + "reminderOn": "Attivo", + "reminderOff": "Disattivo", + "supplyLabel": "Scorta", + "supplyRunway": "Copre ancora circa {days} giorni", + "jumpToZeitplan": "Apri programma", + "jumpToBestand": "Apri scorta", "complianceTitle": "Aderenza", "complianceEmpty": "Ancora nessun dato sull'aderenza." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Programma {index}", "fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».", "graceEcho": "Finestra di promemoria: {minutes} minuti", - "graceLink": "Modifica in «Promemoria»", + "reminderTitle": "Promemoria", "save": "Salva programma", "saved": "Programma salvato", "failed": "Impossibile salvare il programma", @@ -1714,8 +1712,7 @@ "dragHandle": "Trascina per riordinare", "show": "Mostra", "hide": "Nascondi", - "manageSubPages": "Gestisci pagine di dettaglio", - "manageSubPagesHint": "Le pagine nascoste vengono rimosse anche dalla navigazione in alto.", + "manageInSettings": "Gestisci schede e pagine di dettaglio nelle impostazioni", "groupSleep": "Sonno", "groupMood": "Umore", "groupEvents": "Eventi", @@ -1843,7 +1840,10 @@ "emptyTitle": "Ancora nessun briefing", "emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.", "emptyAction": "Genera briefing", - "loadingLabel": "Preparazione del briefing" + "loadingLabel": "Preparazione del briefing", + "noProviderTitle": "Nessun provider IA collegato", + "noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.", + "noProviderAction": "Apri impostazioni IA" }, "trendsRow": { "title": "Tendenze", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Ordina le schede di navigazione", - "description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.", + "description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio. Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto.", "dragHandle": "Riordina", "saveSuccess": "Ordine delle schede salvato.", "saveError": "Salvataggio dell'ordine delle schede non riuscito.", @@ -3158,6 +3158,8 @@ "empty": "Nessuna scheda di navigazione ancora disponibile." }, "customize": "Personalizza gli Approfondimenti", + "regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.", + "regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.", "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Profilo", "language": "Lingua", "languageDescription": "Scegli la lingua di visualizzazione dell’applicazione.", + "timeFormat": { + "title": "Formato orario", + "description": "Determina se gli orari vengono mostrati nel formato 24 ore o 12 ore (AM/PM).", + "auto": "Automatico (lingua)", + "h24": "24 ore", + "h12": "12 ore (AM/PM)", + "saved": "Formato orario salvato.", + "saveError": "Salvataggio del formato orario non riuscito." + }, "username": "Nome utente", "height": "Altezza (cm)", "dateOfBirth": "Data di nascita", diff --git a/messages/pl.json b/messages/pl.json index 632887aa3..65f77219c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1282,19 +1282,6 @@ "paused": "Wstrzymany", "ended": "Zakończony" }, - "today": { - "groupLabel": "Dzisiejsza dawka", - "taken": "Przyjęto", - "skipped": "Pominięto", - "toastTaken": "Dzisiejsza dawka zapisana", - "toastSkipped": "Dzisiejsza dawka pominięta", - "recordedTaken": "Przyjęto dziś o {time}", - "recordedSkipped": "Pominięto dziś o {time}", - "recordedOneShot": "Jednorazową dawkę przyjęto o {time}", - "noneScheduled": "Na dziś nie zaplanowano żadnej dawki.", - "pausedHint": "Wstrzymano — dziś brak przypomnienia.", - "error": "Nie udało się zapisać dawki." - }, "cadence": { "oneShotOn": "Dawka jednorazowa w dniu {date}.", "oneShotPending": "Dawka jednorazowa oczekuje." @@ -1349,6 +1336,9 @@ "add": "Wpis", "markTaken": "Przyjęte", "markSkipped": "Pominięte", + "rowActionsLabel": "Akcje dla {time}", + "markAsTaken": "Oznacz jako przyjętą", + "markAsSkipped": "Oznacz jako pominiętą", "adHocTag": "poza planem", "takenDetail": "planowo {planned} · przyjęto {taken}", "status": { @@ -1392,8 +1382,8 @@ "requiresCourseWindow": "Fazy są dostępne po ustawieniu okresu leczenia." }, "grace": { - "label": "Okno przypomnienia — dotyczy głównego harmonogramu", - "primaryScheduleNote": "Leki z wieloma harmonogramami zachowują domyślne okno pozostałych harmonogramów.", + "label": "Okno przypomnienia", + "appliesToAllNote": "Dotyczy wszystkich harmonogramów tego leku.", "unit": "minut", "saved": "Okno przypomnienia zapisane", "failed": "Nie udało się zapisać okna przypomnienia." @@ -1468,7 +1458,6 @@ "title": "Ustawienia zaawansowane", "group": { "data": "Dane", - "reminders": "Przypomnienia", "lifecycle": "Cykl życia", "externalApi": "Zewnętrzne API" }, @@ -1487,8 +1476,7 @@ } }, "header": { - "historyLabel": "Zobacz historię przyjęć", - "advancedLabel": "Ustawienia zaawansowane" + "historyLabel": "Zobacz historię przyjęć" }, "history": { "back": "Powrót do leku", @@ -1498,7 +1486,6 @@ "tabs": { "uebersicht": "Przegląd", "zeitplan": "Harmonogram", - "erinnerung": "Przypomnienia", "bestand": "Zapas", "verlauf": "Historia", "injektion": "Iniekcja", @@ -1510,6 +1497,17 @@ "tablistLabel": "Sekcje szczegółów leku" }, "uebersicht": { + "statusTitle": "Status", + "nextDoseLabel": "Następna dawka", + "nextDoseNone": "Brak nadchodzącej dawki.", + "pausedHint": "Wstrzymano — brak przypomnień.", + "reminderLabel": "Przypomnienie", + "reminderOn": "Włączone", + "reminderOff": "Wyłączone", + "supplyLabel": "Zapas", + "supplyRunway": "Wystarczy na około {days} dni", + "jumpToZeitplan": "Otwórz harmonogram", + "jumpToBestand": "Otwórz zapas", "complianceTitle": "Przestrzeganie", "complianceEmpty": "Brak danych o przestrzeganiu." }, @@ -1542,7 +1540,7 @@ "scheduleLabel": "Harmonogram {index}", "fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.", "graceEcho": "Okno przypomnienia: {minutes} minut", - "graceLink": "Zmień w „Przypomnienia”", + "reminderTitle": "Przypomnienie", "save": "Zapisz harmonogram", "saved": "Zapisano harmonogram", "failed": "Nie udało się zapisać harmonogramu", @@ -1714,8 +1712,7 @@ "dragHandle": "Przeciągnij, aby zmienić kolejność", "show": "Pokaż", "hide": "Ukryj", - "manageSubPages": "Zarządzaj stronami szczegółów", - "manageSubPagesHint": "Ukryte strony znikają również z górnej nawigacji.", + "manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach", "groupSleep": "Sen", "groupMood": "Nastrój", "groupEvents": "Zdarzenia", @@ -1843,7 +1840,10 @@ "emptyTitle": "Jeszcze brak briefingu", "emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.", "emptyAction": "Wygeneruj briefing", - "loadingLabel": "Przygotowanie briefingu" + "loadingLabel": "Przygotowanie briefingu", + "noProviderTitle": "Brak połączonego dostawcy AI", + "noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.", + "noProviderAction": "Otwórz ustawienia AI" }, "trendsRow": { "title": "Trendy", @@ -3150,7 +3150,7 @@ }, "pillOrder": { "title": "Sortuj pigułki nawigacyjne", - "description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.", + "description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka. Ukryte strony szczegółów znikają również z górnej nawigacji.", "dragHandle": "Zmień kolejność", "saveSuccess": "Zapisano kolejność pigułek.", "saveError": "Zapisanie kolejności pigułek nie powiodło się.", @@ -3158,6 +3158,8 @@ "empty": "Brak dostępnych pigułek nawigacyjnych." }, "customize": "Dostosuj Analizy", + "regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.", + "regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.", "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę." }, "thresholds": { @@ -3555,6 +3557,15 @@ "profile": "Profil", "language": "Język", "languageDescription": "Wybierz język wyświetlania aplikacji.", + "timeFormat": { + "title": "Format godziny", + "description": "Określa, czy godziny są wyświetlane w formacie 24-godzinnym czy 12-godzinnym (AM/PM).", + "auto": "Automatycznie (język)", + "h24": "24-godzinny", + "h12": "12-godzinny (AM/PM)", + "saved": "Zapisano format godziny.", + "saveError": "Nie udało się zapisać formatu godziny." + }, "username": "Nazwa użytkownika", "height": "Wzrost (cm)", "dateOfBirth": "Data urodzenia", diff --git a/prisma/migrations/0138_v11520_time_format/migration.sql b/prisma/migrations/0138_v11520_time_format/migration.sql new file mode 100644 index 000000000..27506091e --- /dev/null +++ b/prisma/migrations/0138_v11520_time_format/migration.sql @@ -0,0 +1,14 @@ +-- v1.15.20 — per-user hour-cycle display preference. +-- +-- The UI locale alone decided whether times rendered as AM/PM or 24-hour, +-- which left e.g. an en-locale user no way to read 24-hour times. The new +-- column carries an explicit preference: AUTO follows the locale convention, +-- H12 forces AM/PM, H24 forces 24-hour. Display-time only — stored instants +-- stay UTC. +-- +-- Additive + non-destructive: a new NOT NULL column with a default, no +-- backfill needed. Existing rows read AUTO. +CREATE TYPE "time_format_preference" AS ENUM ('AUTO', 'H12', 'H24'); + +ALTER TABLE "users" + ADD COLUMN "time_format" "time_format_preference" NOT NULL DEFAULT 'AUTO'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ced5f92e..dbd24fb9e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -132,6 +132,10 @@ model User { // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric" + // Hour-cycle display preference. AUTO follows the locale convention + // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour. + // Display-time only — canonical storage stays UTC instants. + timeFormat TimeFormatPreference @default(AUTO) @map("time_format") // AI provider selection for insights. Null = OPENAI fallback. aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" aiModel String? @map("ai_model") @@ -716,6 +720,16 @@ enum GlucoseContext { @@map("glucose_context") } +// Hour-cycle display preference for rendered times. AUTO defers to the +// active locale's convention; H12/H24 pin the cycle regardless of locale. +enum TimeFormatPreference { + AUTO + H12 + H24 + + @@map("time_format_preference") +} + enum MeasurementSource { MANUAL WITHINGS diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 22b43748f..429d331d8 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -67,6 +67,9 @@ export const GET = apiHandler(async () => { // storage stays SI; this only drives the display-time transform // branch. Null defaults to "metric" on the client. unitPreference: user.unitPreference === "imperial" ? "imperial" : "metric", + // Hour-cycle display preference (AUTO follows the locale convention, + // H12 / H24 pin the cycle). Clients mirror this into their formatters. + timeFormat: user.timeFormat ?? "AUTO", lastReportPracticeName: user.lastReportPracticeName ?? null, // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the // column is absent (partial-deploy rollback safety, see migration diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 82b9c1b4b..26607d210 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -200,4 +200,50 @@ describe("PATCH /api/user/profile", () => { expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); }); + + // v1.15.20 — hour-cycle display preference rides the shared profile + // update path: a valid enum value persists field-by-field and echoes + // back; anything outside AUTO/H12/H24 is rejected by the Zod schema. + it("persists a valid timeFormat and echoes it back", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "marc", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + timeFormat: "H24", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ timeFormat: "H24" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "user-1" }, + data: expect.objectContaining({ timeFormat: "H24" }), + }), + ); + const body = (await res.json()) as { + data: { timeFormat: string }; + }; + expect(body.data.timeFormat).toBe("H24"); + }); + + it("returns 422 for an invalid timeFormat", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH(req({ timeFormat: "12h" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 1722858a5..cf799b456 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -36,6 +36,7 @@ export const GET = apiHandler(async () => { heightCm: true, locale: true, timezone: true, + timeFormat: true, moodReminderEnabled: true, fullName: true, insurerName: true, @@ -64,6 +65,7 @@ export const GET = apiHandler(async () => { heightCm: dbUser?.heightCm ?? null, locale: dbUser?.locale ?? null, timezone: dbUser?.timezone ?? "Europe/Berlin", + timeFormat: dbUser?.timeFormat ?? "AUTO", moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, @@ -96,6 +98,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { heightCm: result.user.heightCm, locale: result.user.locale, timezone: result.user.timezone, + timeFormat: result.user.timeFormat, moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index 1c389a778..b3f9c531c 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -12,10 +12,6 @@ import { DEFAULT_INSIGHTS_LAYOUT, type InsightsSectionId, } from "@/lib/insights-layout"; -import { - MANAGER_GROUP_ORDER, - SUB_PAGE_MANAGER_GROUP_SLUGS, -} from "@/lib/insights/sub-page-metric"; function render(node: React.ReactNode) { const queryClient = new QueryClient({ @@ -28,31 +24,38 @@ function render(node: React.ReactNode) { ); } -describe(" — broadened sub-page manager (v1.15.14 W2)", () => { +describe(" — detail-page manager retired (v1.15.20)", () => { const noneGated: ReadonlySet = new Set(); - it("relabels the Vitals disclosure to 'Manage detail pages'", () => { - const html = render( + function renderEditMode(): string { + return render( {}} />, ); - expect(html).toContain('data-slot="insights-edit-tiles-disclosure"'); - expect(html).toContain("Manage detail pages"); + } + + it("no longer renders the per-detail-page manager disclosure", () => { + // Pill sorting + visibility moved to Settings → Insights; the in-card + // disclosure under the Vitals row duplicated that surface. + const html = renderEditMode(); + expect(html).not.toContain('data-slot="insights-edit-tiles-disclosure"'); + expect(html).not.toContain('data-slot="insights-edit-tile-row"'); + expect(html).not.toContain('data-slot="insights-edit-tiles-nav-hint"'); }); - it("groups every routed sub-page slug under exactly one manager group", () => { - // The disclosure body is collapsed in SSR (no effects fire), so this - // asserts the data the manager renders FROM rather than the open DOM: - // every group in the manager order carries at least one slug, and the - // three tab-strip-flat categories (sleep / mood / events) are present. - expect(MANAGER_GROUP_ORDER).toContain("mood"); - expect(MANAGER_GROUP_ORDER).toContain("events"); - expect(MANAGER_GROUP_ORDER).toContain("sleep"); - // A non-vitals slug (`steps`) lands in the activity group so it is - // toggle-able from the manager — the round-trip the spec calls out. - expect(SUB_PAGE_MANAGER_GROUP_SLUGS.activity).toContain("steps"); + it("links to the settings pill section instead", () => { + const html = renderEditMode(); + expect(html).toContain('data-slot="insights-edit-manage-link"'); + expect(html).toContain('href="/settings/insights#insights-pill-order"'); + expect(html).toContain("Manage pills & detail pages in Settings"); + }); + + it("still renders the section rows with their eye toggles", () => { + const html = renderEditMode(); + expect(html).toContain('data-slot="insights-edit-section-row"'); + expect(html).toContain('data-slot="insights-edit-section-eye"'); }); }); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 6fe2599ec..de0491978 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -1,10 +1,10 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { - ChevronDown, Eye, EyeOff, GripVertical, @@ -33,22 +33,12 @@ import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { - reorderById, - rebuildTilesWithReorderedVitals, -} from "@/lib/insights-layout-reorder"; +import { reorderById } from "@/lib/insights-layout-reorder"; import { type InsightsLayout, type InsightsSectionConfig, type InsightsSectionId, - type InsightsTileConfig, } from "@/lib/insights-layout"; -import { - MANAGER_GROUP_ORDER, - SUB_PAGE_MANAGER_GROUP_SLUGS, - type ManagerGroup, -} from "@/lib/insights/sub-page-metric"; -import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; /** * v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights @@ -61,14 +51,11 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; * and invalidates `queryKeys.insightsLayout()`; "Zurücksetzen" DELETEs to * defaults. * - * Tile-level depth (the Vitals section's per-metric tiles) ships as the - * spec-sanctioned DISCLOSURE fallback rather than nested cross-container drag: - * section rows drag in ONE top-level `SortableContext`; the Vitals row carries a - * "Kacheln verwalten" disclosure that opens a SEPARATE, independent - * `DndContext` + `SortableContext` for its tiles. The two drag contexts never - * share collision detection, so a tile drag can never cross-fire into a section - * drag — the failure mode flagged in the plan's Risks section. Still inline, - * still drag + eye per tile, robust on touch. + * Tile-level management (the per-metric detail pages + their nav pills) + * moved to Settings → Insights in v1.15.20 — the pill-order section there + * carries both sorting AND the eye toggles, so the disclosure this card used + * to nest under the Vitals row was a duplicate surface. The card keeps the + * draft's `tiles` untouched and links to the settings section instead. */ /** Localized title key per section id — used for the edit-card label. */ @@ -83,27 +70,6 @@ const SECTION_TITLE_KEYS: Record = { "rhythm-events": "insights.rhythmEvents.sectionTitle", }; -/** - * v1.15.14 W2 — group-header label key per manager group. Reuses the - * tab-strip parent-pill labels (`insights.tabStrip.Parent.label`) - * so a section header in the manager reads exactly like the nav pill it - * governs; the three groups the tab strip never collapses (sleep / mood / - * events) get their own `insights.editMode.group*` keys. ONE source of - * labels keeps the customize surface and the nav in lockstep. - */ -const MANAGER_GROUP_HEADER_KEYS: Record = { - vitals: "insights.tabStrip.vitalsParent.label", - body: "insights.tabStrip.bodyParent.label", - activity: "insights.tabStrip.activityParent.label", - sleep: "insights.editMode.groupSleep", - cardiovascular: "insights.tabStrip.cardiovascularParent.label", - hearing: "insights.tabStrip.hearingParent.label", - environment: "insights.tabStrip.environmentParent.label", - metabolic: "insights.tabStrip.metabolicParent.label", - mood: "insights.editMode.groupMood", - events: "insights.editMode.groupEvents", -}; - interface InsightsEditModeProps { /** Resolved layout currently in effect (server copy, defaults while loading). */ layout: InsightsLayout; @@ -131,9 +97,10 @@ export function InsightsEditMode({ const [draft, setDraft] = useState(() => ({ version: layout.version, sections: [...layout.sections].sort((a, b) => a.order - b.order), + // Tiles pass through the save verbatim — pill order + visibility are + // managed on Settings → Insights since v1.15.20. tiles: [...layout.tiles].sort((a, b) => a.order - b.order), })); - const [tilesOpen, setTilesOpen] = useState(false); // v1.15.11 QA L5 — on mount move focus to the edit-card heading so keyboard / // screen-reader users land on the surface they just opened (not the top of @@ -208,34 +175,6 @@ export function InsightsEditMode({ ); const sectionIds = sections.map((s) => s.id); - // v1.15.14 W2 — the manager now lists EVERY sub-page slug grouped by - // category, not just the Vitals overview grid subset. Each group renders - // its slugs sorted by the draft's saved `order`; a slug the layout does - // not enumerate falls to the end. The `overview` tile is layout-only (the - // mother page, not a sub-page) so it never appears here. Labels reuse the - // tab-strip `SUB_PAGE_TABS[slug].labelKey` so the manager row reads like - // the nav pill it governs. - const tilesByGroup = useMemo(() => { - const byId = new Map( - draft.tiles.map((tt) => [tt.id, tt]), - ); - return MANAGER_GROUP_ORDER.map((group) => { - const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group] - .map((slug) => { - const cfg = byId.get(slug); - return { - id: slug as string, - labelKey: SUB_PAGE_TABS[slug].labelKey, - visible: cfg?.visible ?? false, - order: cfg?.order ?? Number.MAX_SAFE_INTEGER, - }; - }) - .sort((a, b) => a.order - b.order); - return { group, rows }; - }).filter((g) => g.rows.length > 0); - // Re-derive on any tile change. - }, [draft.tiles]); - function toggleSection(id: InsightsSectionId, visible: boolean) { setDraft((d) => ({ ...d, @@ -254,47 +193,6 @@ export function InsightsEditMode({ setDraft((d) => ({ ...d, sections: reordered })); } - function toggleTile(id: string, visible: boolean) { - setDraft((d) => ({ - ...d, - tiles: d.tiles.map((tt) => (tt.id === id ? { ...tt, visible } : tt)), - })); - } - - /** - * v1.15.14 W2 — reorder the tiles WITHIN one manager group. Each group - * renders its own `SortableContext`, so a drag never crosses a group - * boundary; the group is identified from the dragged slug. We reorder the - * group's subset through the tested pure helper, then splice it back into - * the full `tiles` array (which also drives the tab strip + overview grid), - * re-densifying order only across that group's slots while every other - * tile keeps its relative order. - */ - function handleGroupTileDragEnd(group: ManagerGroup, event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id) return; - const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? []; - const groupMemberIds = new Set(groupRows.map((r) => r.id)); - const subset = groupRows.map((tt) => ({ - id: tt.id, - order: tt.order, - visible: tt.visible, - })); - // Reorder ONLY this group's subset through the tested pure helper — a - // single total-order sort, never a mixed-key comparator (QA M2). - const reordered = reorderById(subset, String(active.id), String(over.id)); - const reorderedIds = reordered.map((r) => r.id); - - setDraft((d) => ({ - ...d, - // Substitute this group's slots in their new relative order while leaving - // every other tile untouched. Pure, total-order helper (QA M2). - tiles: rebuildTilesWithReorderedVitals(d.tiles, reorderedIds, (id) => - groupMemberIds.has(id), - ), - })); - } - const allHidden = draft.sections.every((s) => !s.visible); return ( @@ -365,7 +263,6 @@ export function InsightsEditMode({
    {sections.map((section) => { const gatedOff = gatedOffSectionIds.has(section.id); - const isVitals = section.id === "vitals"; return ( - {isVitals && ( -
    - - {/* v1.15.14 — flag that hiding a detail page also drops - its top-nav pill, so a user isn't surprised a metric - vanished from the navigation. */} -

    - {t("insights.editMode.manageSubPagesHint")} -

    - {tilesOpen && ( - // v1.15.14 W2 — the manager now lists EVERY sub-page - // slug grouped by category, the same `tiles` layout the - // tab strip + overview Vitals grid read. Each group is - // its OWN `DndContext` + `SortableContext` so a drag - // never crosses a group boundary (and never cross-fires - // into the section drag). The whole list scrolls inside - // one capped container; `overscroll-contain` keeps the - // scroll local on touch. -
    - {tilesByGroup.map(({ group, rows }) => ( -
    -

    - {t(MANAGER_GROUP_HEADER_KEYS[group])} -

    - - handleGroupTileDragEnd(group, event) - } - > - r.id)} - strategy={verticalListSortingStrategy} - > -
    - {rows.map((tile) => ( - - ))} -
    -
    -
    -
    - ))} -
    - )} -
    - )} -
    + /> ); })}
    + + {/* v1.15.20 — the per-detail-page manager (sort + show/hide) lives on + Settings → Insights; the disclosure this card used to nest under + the Vitals row duplicated it. Keep a quiet pointer instead. */} +

    + + {t("insights.editMode.manageInSettings")} + +

    ); } @@ -523,7 +351,6 @@ interface SortableSectionRowProps { disabled: boolean; labels: RowLabels & { gatedHint: string }; onToggle: (id: InsightsSectionId, visible: boolean) => void; - children?: React.ReactNode; } function SortableSectionRow({ @@ -533,7 +360,6 @@ function SortableSectionRow({ disabled, labels, onToggle, - children, }: SortableSectionRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id }); @@ -598,70 +424,7 @@ function SortableSectionRow({ slot="insights-edit-section-eye" />
    - {children}
    ); } -interface SortableTileRowProps { - id: string; - title: string; - visible: boolean; - disabled: boolean; - labels: RowLabels; - onToggle: (id: string, visible: boolean) => void; -} - -function SortableTileRow({ - id, - title, - visible, - disabled, - labels, - onToggle, -}: SortableTileRowProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition: prefersReducedMotion() ? "none" : transition, - }; - - return ( -
    - - - {title} - - onToggle(id, !visible)} - slot="insights-edit-tile-eye" - /> -
    - ); -} diff --git a/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx b/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx new file mode 100644 index 000000000..1b0d65a64 --- /dev/null +++ b/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx @@ -0,0 +1,144 @@ +/** + * Settings → Insights pill list — per-pill visibility toggle (v1.15.20). + * + * Each sortable pill row carries the same eye interaction the overview edit + * cards use; the toggle flips the draft's `tiles[].visible` flag and + * "Speichern" persists it through the same `{ tiles }` PUT as the order. + * `tiles[].visible` is the field the tab strip + overview Vitals grid + * already read (v1.15.14), so hiding a pill here also drops it from the + * top navigation. + * + * SSR-only suite per project convention: static markup pins the rendered + * eye state per tile; the persistence contract is pinned against the same + * fetch stub the save handler issues. + */ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + DEFAULT_INSIGHTS_LAYOUT, + type InsightsLayout, +} from "@/lib/insights-layout"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null, isAuthenticated: true }), +})); + +const layoutSpy = vi.fn< + () => { layout: InsightsLayout; isLoading: boolean; isSuccess: boolean } +>(() => ({ + layout: DEFAULT_INSIGHTS_LAYOUT, + isLoading: false, + isSuccess: true, +})); +vi.mock("@/hooks/use-insights-layout", () => ({ + useInsightsLayoutQuery: () => layoutSpy(), +})); + +import { InsightsPillOrderSection } from "../insights-pill-order-section"; + +function buildLayout(hiddenIds: readonly string[]): InsightsLayout { + return { + version: DEFAULT_INSIGHTS_LAYOUT.version, + sections: DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })), + tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((tile) => ({ + ...tile, + visible: !hiddenIds.includes(tile.id), + })), + }; +} + +beforeEach(() => { + layoutSpy.mockImplementation(() => ({ + layout: DEFAULT_INSIGHTS_LAYOUT, + isLoading: false, + isSuccess: true, + })); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function render(): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" — visibility toggles (v1.15.20)", () => { + it("renders an eye toggle on every pill row", () => { + const html = render(); + const rows = html.match(/data-slot="insights-pill-order-row"/g) ?? []; + const eyes = html.match(/data-slot="insights-pill-order-eye"/g) ?? []; + expect(rows.length).toBeGreaterThan(0); + expect(eyes.length).toBe(rows.length); + }); + + it("marks every default tile visible (aria-pressed eye state)", () => { + const html = render(); + expect(html).not.toContain('data-visible="false"'); + expect(html).toContain('data-visible="true"'); + }); + + it("reflects a hidden tile from the saved layout", () => { + layoutSpy.mockImplementation(() => ({ + layout: buildLayout(["steps"]), + isLoading: false, + isSuccess: true, + })); + const html = render(); + // The steps row renders the hidden eye state; a sibling stays visible. + const stepsRow = html.match( + /]*data-tile="steps"[^>]*>[\s\S]*?<\/div>\s*<\/div>/, + ); + expect(stepsRow).not.toBeNull(); + expect(stepsRow![0]).toContain('data-visible="false"'); + expect(html).toContain('data-visible="true"'); + }); + + it("persists visibility through the layout PUT (tiles carry visible)", async () => { + // SSR can't dispatch a click, so pin the persistence contract against + // the same stub the save handler issues — the `{ version, sections, + // tiles }` blob with per-tile `visible` flags the tab strip reads. + const flipped = buildLayout(["steps"]); + await fetch("/api/insights/layout", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + version: 2, + sections: flipped.sections, + tiles: flipped.tiles, + }), + }); + const stub = fetch as unknown as ReturnType; + const [url, init] = stub.mock.calls[0]!; + expect(url).toBe("/api/insights/layout"); + expect(init?.method).toBe("PUT"); + const body = JSON.parse(init?.body as string) as { + tiles: Array<{ id: string; visible: boolean }>; + }; + expect(body.tiles.find((t) => t.id === "steps")?.visible).toBe(false); + }); +}); diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx new file mode 100644 index 000000000..8398feecf --- /dev/null +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -0,0 +1,152 @@ +/** + * Settings → Profile hour-format dropdown contract (v1.15.20). + * + * The control reads the current value from `useAuth().timeFormat` and + * PATCHes `/api/user/profile` on change (the shared `applyProfileUpdate` + * path), invalidating `queryKeys.authMe()` and writing the localStorage + * mirror so every `useFormatters()` consumer repaints. + * + * Test strategy mirrors ``'s suite — the project's + * SSR-only convention (no `@testing-library/react`): real `useMutation` + + * `QueryClient`, a spied `useAuth`, and a stubbed `fetch`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import type { TimeFormatPreference } from "@/lib/format-locale"; + +const authSpy = vi.fn< + () => { user: AuthUser | null; isAuthenticated: boolean } +>(() => ({ user: buildUser("AUTO"), isAuthenticated: true })); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +function buildUser(timeFormat: TimeFormatPreference): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat, + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + cycleTrackingEnabled: false, + }; +} + +import { TimeFormatSelect } from "../time-format-select"; + +let fetchMock: ReturnType; + +function makeFetch() { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response( + JSON.stringify({ data: { timeFormat: body.timeFormat } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); +} + +beforeEach(() => { + authSpy.mockClear(); + authSpy.mockImplementation(() => ({ + user: buildUser("AUTO"), + isAuthenticated: true, + })); + fetchMock = makeFetch(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function render(isAuthenticated = true): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Settings — TimeFormatSelect", () => { + it("renders a labeled native select with all three options", () => { + const html = render(); + expect(html).toContain('data-testid="settings-time-format-select"'); + expect(html).toContain('value="AUTO"'); + expect(html).toContain('value="H24"'); + expect(html).toContain('value="H12"'); + expect(html).toContain("Automatic (language)"); + expect(html).toContain("24-hour"); + expect(html).toContain("12-hour (AM/PM)"); + }); + + it("reflects a stored H24 preference", () => { + authSpy.mockImplementation(() => ({ + user: buildUser("H24"), + isAuthenticated: true, + })); + const html = render(); + expect(html).toMatch(/]*selected[^>]*value="H24"|]*value="H24"[^>]*selected/); + }); + + it("defaults to AUTO when the user has no stored preference", () => { + authSpy.mockImplementation(() => ({ + user: null, + isAuthenticated: true, + })); + const html = render(); + expect(html).toMatch(/]*selected[^>]*value="AUTO"|]*value="AUTO"[^>]*selected/); + }); + + it("disables the select when unauthenticated", () => { + const html = render(false); + const select = html.match( + /]*settings-time-format-select[^>]*>/, + ); + expect(select).not.toBeNull(); + expect(select![0]).toContain("disabled"); + }); + + it("targets the profile endpoint with a PATCH body", async () => { + // SSR can't dispatch a change event, so pin the mutation contract + // against the same stub the handler issues — endpoint, method, and + // the field-by-field body shape the route's Zod schema accepts. + await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timeFormat: "H24" }), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("/api/user/profile"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(init?.body as string)).toEqual({ timeFormat: "H24" }); + }); +}); diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 6e6583eef..6c01150ee 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -47,6 +47,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference, + timeFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 117dfbc23..6f905d144 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -50,6 +50,7 @@ import { describePasskeyError } from "@/lib/passkey-errors"; import { queryKeys } from "@/lib/query-keys"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; +import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; @@ -506,13 +507,15 @@ export function AccountSection() { - {/* Timezone + unit system share one grid row — both are - personal display preferences (like language above). The - unit dropdown PATCHes its own endpoint on change; the - timezone saves through the form's submit handler. */} + {/* Timezone + unit system + hour format share one grid block — + all personal display preferences (like language above). The + unit and time-format dropdowns PATCH their own endpoints on + change; the timezone saves through the form's submit + handler. */}
    +
    {/* v1.7.0 — optional patient-identity fields surfaced on the diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 20ab000fd..f6add9fad 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { GripVertical, Loader2 } from "lucide-react"; +import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react"; import { DndContext, KeyboardSensor, @@ -45,19 +45,24 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; /** * v1.15.18 — dedicated pill-sort control for the Insights settings section. + * v1.15.20 — pill VISIBILITY joins the same list: each row carries the eye + * toggle the overview edit cards use, flipping `tiles[].visible` (the field + * that already gates both the top-nav pill and the overview Vitals grid since + * v1.15.14). Sorting and show/hide for the detail pages live on ONE surface; + * the on-page edit mode's former "Manage detail pages" disclosure retired in + * favour of a link here. * * The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field * that is ALREADY separate from the overview `sections[].order` in layout v2. - * Until now the pill order could only be changed implicitly through the + * Until v1.15.18 the pill order could only be changed implicitly through the * overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which * the maintainer found unintuitive. This is the explicit surface: drag the - * pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`, - * which the tab strip + overview share via `queryKeys.insightsLayout()`. + * pills, toggle their eyes, "Speichern" PUTs the merged `{ tiles }` blob to + * `/api/insights/layout`, which the tab strip + overview share via + * `queryKeys.insightsLayout()`. * * Drag mirrors `insights-edit-mode` — each manager group is its OWN * `DndContext` + `SortableContext` so a drag never crosses a group boundary. - * The pills here are NOT visibility toggles; visibility lives on the overview - * "Übersicht anordnen" block so the two concerns stay distinct. */ const MANAGER_GROUP_HEADER_KEYS: Record = { @@ -73,11 +78,15 @@ const MANAGER_GROUP_HEADER_KEYS: Record = { events: "insights.editMode.groupEvents", }; -/** Stable id-order fingerprint used to detect when the server copy changed. */ -function orderSignature(tiles: readonly InsightsTileConfig[]): string { +/** + * Stable fingerprint used to detect when the server copy changed. Carries + * both the id order AND each tile's visibility so a save that only flipped + * an eye still re-seeds the draft baseline. + */ +function layoutSignature(tiles: readonly InsightsTileConfig[]): string { return [...tiles] .sort((a, b) => a.order - b.order) - .map((tile) => tile.id) + .map((tile) => `${tile.id}:${tile.visible ? 1 : 0}`) .join(","); } @@ -95,7 +104,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { // ref) keeps the reseed render-safe and idempotent while leaving in-flight // drag edits untouched: the baseline only advances when the *server* order // differs from what we last seeded from. - const serverSignature = orderSignature(layout.tiles); + const serverSignature = layoutSignature(layout.tiles); const [seededSignature, setSeededSignature] = useState(serverSignature); const [draftTiles, setDraftTiles] = useState( () => [...layout.tiles].sort((a, b) => a.order - b.order), @@ -153,6 +162,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { return { id: slug as string, labelKey: SUB_PAGE_TABS[slug].labelKey, + visible: cfg?.visible ?? false, order: cfg?.order ?? Number.MAX_SAFE_INTEGER, }; }) @@ -180,12 +190,21 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { ); } - const dirty = useMemo(() => { - const original = [...layout.tiles].sort((a, b) => a.order - b.order); - if (original.length !== draftTiles.length) return true; - const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order); - return original.some((tt, i) => tt.id !== draftByOrder[i]?.id); - }, [layout.tiles, draftTiles]); + /** + * Flip one tile's visibility in the draft. Persisted on "Speichern" through + * the same `{ tiles }` PUT as the order — `tiles[].visible` is the field the + * tab strip and the overview Vitals grid already read (v1.15.14). + */ + function toggleTileVisible(id: string, visible: boolean) { + setDraftTiles((tiles) => + tiles.map((tile) => (tile.id === id ? { ...tile, visible } : tile)), + ); + } + + const dirty = useMemo( + () => layoutSignature(layout.tiles) !== layoutSignature(draftTiles), + [layout.tiles, draftTiles], + ); return (
    ))} @@ -281,13 +304,21 @@ const DRAG_HANDLE_CLASS = function SortablePillRow({ id, title, + visible, disabled, dragHandleLabel, + showLabel, + hideLabel, + onToggle, }: { id: string; title: string; + visible: boolean; disabled: boolean; dragHandleLabel: string; + showLabel: string; + hideLabel: string; + onToggle: (id: string, visible: boolean) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); @@ -324,6 +355,21 @@ function SortablePillRow({ {title} + {/* v1.15.20 — same eye interaction as the overview edit cards: the + toggle flips the draft's `visible` flag; "Speichern" persists it. */} + ); } diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx new file mode 100644 index 000000000..67edd7da3 --- /dev/null +++ b/src/components/settings/time-format-select.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useAuth } from "@/hooks/use-auth"; +import type { TimeFormatPreference } from "@/lib/format-locale"; +import { storeTimeFormat } from "@/lib/time-format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * Hour-cycle (12h/24h) preference as a Profile dropdown. + * + * Lives beside the language select because it is the same kind of personal + * display preference; the en locale otherwise pins users to the AM/PM + * convention with no way out (and vice versa). AUTO follows the locale + * convention, H24 forces a 24-hour clock, H12 forces AM/PM. + * + * Persistence mirrors ``: a change PATCHes the profile + * endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage + * mirror is written synchronously so every `useFormatters()` consumer and the + * legacy `src/lib/format.ts` helpers repaint without waiting for the refetch. + */ +export function TimeFormatSelect({ + isAuthenticated, + id = "time-format", +}: { + isAuthenticated: boolean; + id?: string; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState( + null, + ); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: TimeFormatPreference = + optimistic ?? user?.timeFormat ?? "AUTO"; + + const mutation = useMutation({ + mutationFn: async (next: TimeFormatPreference) => { + const res = await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timeFormat: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: (next) => { + // Repaint every formatter consumer right away; the /me refetch below + // re-asserts the same value once it settles. + storeTimeFormat(next); + setMsg(t("settings.timeFormat.saved")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error + ? err.message + : t("settings.timeFormat.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: TimeFormatPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + return ( +
    + + handleSelect(e.target.value as TimeFormatPreference)} + > + + + + +

    + {msg ?? t("settings.timeFormat.description")} +

    +
    + ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index cab9c0341..27a4f8ad7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -3,6 +3,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { queryKeys } from "@/lib/query-keys"; +import type { TimeFormatPreference } from "@/lib/format-locale"; +import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; export interface AuthUser { id: string; @@ -36,6 +38,14 @@ export interface AuthUser { * "metric" in `fetchMe`. */ unitPreference: "metric" | "imperial"; + /** + * Hour-cycle display preference. AUTO follows the locale convention + * (en → AM/PM, de → 24h); H12 / H24 pin the cycle regardless of locale. + * Mirrored into localStorage by `fetchMe` so `useFormatters()` and the + * legacy `src/lib/format.ts` helpers render the same clock. Coerced to + * "AUTO" against a stale /me payload. + */ + timeFormat: TimeFormatPreference; /** * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach * mount point (``, ``, the @@ -81,12 +91,22 @@ async function fetchMe(): Promise { role: string; timezone: string; }; + // Hour-cycle preference: coerce against a stale /me payload, then mirror + // into localStorage so `useFormatters()` and the legacy format helpers + // (which cannot reach the query cache) render the same clock. + const timeFormat: TimeFormatPreference = isTimeFormatPreference( + data.timeFormat, + ) + ? data.timeFormat + : "AUTO"; + storeTimeFormat(timeFormat); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, // v1.7.0 — coerce against a stale /me payload (older server image // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", + timeFormat, // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index 2289ec75a..e8f727b0c 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -68,20 +68,63 @@ describe("makeFormatters", () => { expect(enFmt.date(sample)).toContain("2026"); }); - it("formats 24h time in Europe/Berlin regardless of locale", () => { + // v1.15.20 — the hour cycle follows the locale convention by default + // (AUTO). Pre-v1.15.20 both locales were pinned to 24h via `hour12: + // false`; the explicit preference replaced the pin. + it("formats time per the locale convention under AUTO", () => { expect(deFmt.time(sample)).toBe("16:30"); - expect(enFmt.time(sample)).toBe("16:30"); + expect(enFmt.time(sample)).toBe("04:30 PM"); + }); + + describe("hour-cycle preference (v1.15.20)", () => { + it("AUTO follows the locale default for time + dateTime", () => { + const de = makeFormatters("de", undefined, "AUTO"); + const en = makeFormatters("en", undefined, "AUTO"); + expect(de.time(sample)).toBe("16:30"); + expect(en.time(sample)).toBe("04:30 PM"); + expect(de.dateTime(sample)).toBe("18.04.2026, 16:30"); + expect(en.dateTime(sample)).toBe("04/18/2026, 04:30 PM"); + }); + + it("H12 forces AM/PM regardless of locale", () => { + const de = makeFormatters("de", undefined, "H12"); + const en = makeFormatters("en", undefined, "H12"); + expect(de.time(sample)).toContain("PM"); + expect(en.time(sample)).toBe("04:30 PM"); + expect(de.dateTime(sample)).toContain("PM"); + }); + + it("H24 forces the 24-hour clock regardless of locale", () => { + const de = makeFormatters("de", undefined, "H24"); + const en = makeFormatters("en", undefined, "H24"); + expect(de.time(sample)).toBe("16:30"); + expect(en.time(sample)).toBe("16:30"); + expect(en.dateTime(sample)).toBe("04/18/2026, 16:30"); + }); + + it("H24 renders midnight as 00, never 24 (h23 cycle)", () => { + const midnight = new Date("2026-04-18T22:30:00Z"); // 00:30 Berlin CEST + const en = makeFormatters("en", undefined, "H24"); + expect(en.time(midnight)).toBe("00:30"); + }); + + it("does not affect date-only formatters", () => { + const auto = makeFormatters("en", undefined, "AUTO"); + const h24 = makeFormatters("en", undefined, "H24"); + expect(auto.date(sample)).toBe(h24.date(sample)); + expect(auto.dateShort(sample)).toBe(h24.dateShort(sample)); + }); }); // v1.4.25 W7 — formatters accept a per-user timezone override. it("renders time in the user's tz when userTz is passed", () => { - const tokyo = makeFormatters("en", "Asia/Tokyo"); + const tokyo = makeFormatters("en", "Asia/Tokyo", "H24"); // 14:30 UTC = 23:30 Tokyo expect(tokyo.time(sample)).toBe("23:30"); }); it("renders time in Europe/Berlin when userTz is empty", () => { - const fallback = makeFormatters("en", ""); + const fallback = makeFormatters("en", "", "H24"); expect(fallback.time(sample)).toBe("16:30"); }); diff --git a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts b/src/lib/analytics/__tests__/schedule-compliance-select.test.ts new file mode 100644 index 000000000..40f603ba2 --- /dev/null +++ b/src/lib/analytics/__tests__/schedule-compliance-select.test.ts @@ -0,0 +1,38 @@ +/** + * v1.15.20 — pins the shared schedule select for compliance call sites. + * + * Every surface that loads schedules for a compliance computation routes + * through `SCHEDULE_COMPLIANCE_SELECT`. The constant must keep selecting + * every field the engine consumes — most importantly `doseWindows`, whose + * absence silently degrades a user-configured on-time band back to the + * symmetric default on that surface (the v1.15.20 audit gap: only 2 of 8 + * call sites selected it). + */ +import { describe, expect, it } from "vitest"; + +import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance"; + +describe("SCHEDULE_COMPLIANCE_SELECT", () => { + it("selects every schedule field the compliance engine consumes", () => { + expect(SCHEDULE_COMPLIANCE_SELECT).toEqual({ + id: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + timesOfDay: true, + reminderGraceMinutes: true, + rrule: true, + rollingIntervalDays: true, + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, + doseWindows: true, + }); + }); + + it("keeps the configured per-dose windows on the wire", () => { + // The one field whose loss is silent: the engine falls back to the + // ±1 h default and every number still "looks" right. + expect(SCHEDULE_COMPLIANCE_SELECT.doseWindows).toBe(true); + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index e2901c223..9c2eea443 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -20,6 +20,9 @@ const extendedProfileSchema = profileSchema.extend({ .refine(isValidTimezone, "Invalid IANA timezone") .optional(), moodReminderEnabled: z.boolean().optional(), + // Hour-cycle display preference. AUTO follows the locale convention, + // H12 forces AM/PM, H24 forces 24-hour. + timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(), }); export interface ApplyProfileResult { @@ -35,6 +38,7 @@ export interface ApplyProfileResult { gender: string | null; timezone: string; locale: string | null; + timeFormat: "AUTO" | "H12" | "H24"; moodReminderEnabled: boolean; // v1.7.0 — patient-identity fields. `insuranceNumber` is returned in // plaintext (decrypted on read by the route) so the client can render @@ -101,6 +105,7 @@ export async function applyProfileUpdate( } if (data.locale !== undefined) updates.locale = data.locale; if (data.timezone !== undefined) updates.timezone = data.timezone; + if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat; if (data.moodReminderEnabled !== undefined) { updates.moodReminderEnabled = data.moodReminderEnabled; } @@ -175,6 +180,7 @@ export async function applyProfileUpdate( gender: updatedUser.gender, timezone: updatedUser.timezone, locale: updatedUser.locale, + timeFormat: updatedUser.timeFormat ?? "AUTO", moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 6de1643df..b57bb591c 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -42,6 +42,31 @@ export function resolveIntlLocale(locale: Locale): string { return INTL_LOCALE_MAP[locale] ?? "en-US"; } +/** + * Hour-cycle display preference (mirrors the `TimeFormatPreference` Prisma + * enum). AUTO defers to the locale's own convention (en-US → AM/PM, de-DE → + * 24h); H12 forces AM/PM; H24 forces a 24-hour clock ("h23": midnight is + * 00:30, never 24:30). Display-time only — stored instants stay UTC. + */ +export type TimeFormatPreference = "AUTO" | "H12" | "H24"; + +/** + * Intl options for the requested hour cycle. AUTO contributes nothing so + * `Intl.DateTimeFormat` falls back to the locale default. + */ +function hourCycleOptions( + timeFormat: TimeFormatPreference, +): Intl.DateTimeFormatOptions { + switch (timeFormat) { + case "H12": + return { hour12: true }; + case "H24": + return { hourCycle: "h23" }; + default: + return {}; + } +} + type DateInput = Date | string | number; function asDate(value: DateInput): Date { @@ -59,9 +84,9 @@ export interface Formatters { date: (value: DateInput) => string; /** Short date without year, e.g. "19.02." / "Feb 19". */ dateShort: (value: DateInput) => string; - /** Full date + time in 24h. */ + /** Full date + time; hour cycle follows the `timeFormat` preference. */ dateTime: (value: DateInput) => string; - /** Time only in 24h, e.g. "14:30". */ + /** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */ time: (value: DateInput) => string; /** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */ dateWithWeekday: (value: DateInput) => string; @@ -69,9 +94,14 @@ export interface Formatters { monthShort: (value: DateInput) => string; } -export function makeFormatters(locale: Locale, userTz?: string): Formatters { +export function makeFormatters( + locale: Locale, + userTz?: string, + timeFormat: TimeFormatPreference = "AUTO", +): Formatters { const intlLocale = resolveIntlLocale(locale); const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE; + const hourOpts = hourCycleOptions(timeFormat); return { number: (value, fractionDigits) => @@ -115,7 +145,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters { year: "numeric", hour: "2-digit", minute: "2-digit", - hour12: false, + ...hourOpts, }), time: (value) => @@ -123,7 +153,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters { timeZone: tz, hour: "2-digit", minute: "2-digit", - hour12: false, + ...hourOpts, }), dateWithWeekday: (value) => diff --git a/src/lib/format.ts b/src/lib/format.ts index aa4633746..021b90af9 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -24,6 +24,7 @@ */ import { makeFormatters } from "./format-locale"; +import { readStoredTimeFormat } from "./time-format"; import { locales, type Locale } from "./i18n/config"; function isLocale(value: string | null | undefined): value is Locale { @@ -45,7 +46,10 @@ function activeLocale(): Locale { } function formatters() { - return makeFormatters(activeLocale()); + // Honour the mirrored hour-cycle preference so these legacy helpers render + // the same clock as `useFormatters()` call sites. SSR reads AUTO — same + // post-hydration caveat as `activeLocale()` above. + return makeFormatters(activeLocale(), undefined, readStoredTimeFormat()); } /** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */ diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 06d80c3fa..2158e2405 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -7,10 +7,19 @@ import { useMemo, useState, useCallback, + useSyncExternalStore, type ReactNode, } from "react"; import { locales, defaultLocale, type Locale } from "./config"; -import { makeFormatters, type Formatters } from "../format-locale"; +import { + makeFormatters, + type Formatters, + type TimeFormatPreference, +} from "../format-locale"; +import { + readStoredTimeFormat, + subscribeTimeFormat, +} from "../time-format"; import { allMessages, resolveKey } from "./shared-resolve"; interface I18nContextValue { @@ -133,12 +142,32 @@ export function useTranslations() { return context; } +/** + * The user's hour-cycle preference (AUTO / H12 / H24), reactive to changes. + * Backed by the localStorage mirror that `useAuth`'s `/api/auth/me` fetch + * and the profile time-format select keep in sync with the server value — + * no QueryClient required in the tree. SSR resolves AUTO. + */ +export function useTimeFormatPreference(): TimeFormatPreference { + return useSyncExternalStore( + subscribeTimeFormat, + readStoredTimeFormat, + () => "AUTO" as const, + ); +} + /** * Locale-aware formatters tied to the active UI locale. Use for every number, * date, and time rendered in the UI so regional conventions (70,5 vs 70.5, - * 19.02.2026 vs Feb 19, 2026) follow the user's language choice. + * 19.02.2026 vs Feb 19, 2026) follow the user's language choice. Times honour + * the per-user hour-cycle preference (AUTO follows the locale, H12 / H24 pin + * the cycle). */ export function useFormatters(): Formatters { const { locale } = useTranslations(); - return useMemo(() => makeFormatters(locale), [locale]); + const timeFormat = useTimeFormatPreference(); + return useMemo( + () => makeFormatters(locale, undefined, timeFormat), + [locale, timeFormat], + ); } diff --git a/src/lib/time-format.ts b/src/lib/time-format.ts new file mode 100644 index 000000000..f9863d9c7 --- /dev/null +++ b/src/lib/time-format.ts @@ -0,0 +1,71 @@ +/** + * Client-side mirror of the per-user hour-cycle preference. + * + * The server-authoritative value lives on the user row (`users.time_format`, + * surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror + * follows the `healthlog-locale` pattern: `fetchMe` writes the resolved value + * into localStorage so that + * + * 1. `useFormatters()` can read it through `useSyncExternalStore` without + * requiring a QueryClient in the tree, and + * 2. the legacy helpers in `src/lib/format.ts` (plain functions, no React + * context) render the same hour cycle as hook-based call sites. + * + * SSR always resolves AUTO (`window` is undefined); the same caveat as the + * locale formatters applies — current call sites render their formatted + * strings post-fetch, so there is no hydration-mismatch path today. + */ + +import type { TimeFormatPreference } from "./format-locale"; + +const STORAGE_KEY = "healthlog-time-format"; +const CHANGE_EVENT = "healthlog:time-format-change"; + +export const TIME_FORMAT_PREFERENCES = ["AUTO", "H12", "H24"] as const; + +export function isTimeFormatPreference( + value: unknown, +): value is TimeFormatPreference { + return ( + typeof value === "string" && + (TIME_FORMAT_PREFERENCES as readonly string[]).includes(value) + ); +} + +/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */ +export function readStoredTimeFormat(): TimeFormatPreference { + if (typeof window === "undefined") return "AUTO"; + try { + const stored = window.localStorage?.getItem(STORAGE_KEY); + return isTimeFormatPreference(stored) ? stored : "AUTO"; + } catch { + return "AUTO"; + } +} + +/** + * Persist the mirror and notify same-tab subscribers. Cross-tab updates ride + * the browser's native `storage` event. + */ +export function storeTimeFormat(value: TimeFormatPreference): void { + if (typeof window === "undefined") return; + try { + const previous = window.localStorage?.getItem(STORAGE_KEY); + if (previous === value) return; + window.localStorage?.setItem(STORAGE_KEY, value); + } catch { + return; + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +/** `useSyncExternalStore`-shaped subscription for the mirror. */ +export function subscribeTimeFormat(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onChange); + window.removeEventListener("storage", onChange); + }; +} From d7bf0df10b73530598cce179c27d5734491fafa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:05:05 +0200 Subject: [PATCH 08/21] chore(ci): pin the supply chain and surface build provenance Every workflow action is pinned to a commit SHA, dependabot auto-merge is limited to patch and minor updates behind the now-required status checks on main, and the audit, Trivy and ESLint gates block instead of warning. The published image finally carries its build SHA and build timestamp so the version endpoint can prove which commit is running. The API-token HMAC key fails closed under 32 characters, compose pins the database minor and denies privilege escalation, and the changelog drops working-note references in favour of neutral wording. --- .github/workflows/dependabot-auto-merge.yml | 35 +++++++- .github/workflows/docker-publish.yml | 40 ++++++--- .github/workflows/e2e.yml | 13 +-- .github/workflows/env-check.yml | 6 +- .github/workflows/integration.yml | 9 +- .github/workflows/knip.yml | 6 +- .github/workflows/no-todo-markers.yml | 2 +- .github/workflows/post-publish-verify.yml | 2 +- .github/workflows/security.yml | 39 +++++---- CHANGELOG.md | 96 ++++++++++----------- Dockerfile | 28 ++++++ docker-compose.yml | 15 +++- src/lib/auth/__tests__/hmac.test.ts | 17 +++- src/lib/auth/hmac.ts | 13 ++- 14 files changed, 222 insertions(+), 99 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 2a0d092b1..3b5721a18 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -12,11 +12,11 @@ jobs: if: github.actor == 'dependabot[bot]' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" @@ -27,7 +27,34 @@ jobs: - run: pnpm test - - run: gh pr merge --auto --squash "$PR_URL" + # Auto-merge is gated on the Dependabot-declared update type: + # patch and minor bumps merge once required checks pass; major + # bumps get a comment and wait for the maintainer. A major bump + # routinely carries breaking changes the unit suite alone cannot + # vouch for. + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge (patch / minor only) + if: >- + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Flag major update for manual review + if: steps.metadata.outputs.update-type == 'version-update:semver-major' + run: >- + gh pr comment "$PR_URL" --body + "Major version bump (${DEP_NAMES}) — auto-merge is intentionally + disabled for semver-major updates. Review the upstream changelog + and merge manually." + env: + PR_URL: ${{ github.event.pull_request.html_url }} + DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3e82b3718..568c346ae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,6 +20,11 @@ on: branches: [main] workflow_dispatch: +# Default for any job that doesn't declare its own block; both jobs +# below elevate to packages:write explicitly. +permissions: + contents: read + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -72,10 +77,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR rejects repository refs with uppercase letters. `IMAGE_NAME` # is derived from `github.repository`, which preserves the case of @@ -93,7 +98,7 @@ jobs: # Image is built but not pushed in that case. - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -101,7 +106,7 @@ jobs: - name: Compute image labels id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_REF }} labels: | @@ -181,6 +186,19 @@ jobs: asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)" echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes." + # Build provenance for /api/version: the short commit SHA and an + # ISO-8601 build timestamp travel into the image as build-args + # (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the + # deploy runbook can verify which commit a running container + # actually carries — the `:latest` pull is the source of truth, + # not the queued deploy status. 12 hex chars is plenty to be + # unambiguous in this repository. + - name: Resolve build provenance + id: provenance + run: | + echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Per-ref + per-platform gha cache scope. Tag builds warm-start # off main's last good layer set for the same platform; tag and # main builds at the same SHA do not contend on writes, and @@ -188,7 +206,7 @@ jobs: # arm64 layer never poisons an amd64 build). - name: Build & push by digest id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile @@ -203,6 +221,8 @@ jobs: # can never survive a cache hit into the new image. build-args: | NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }} + NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }} + NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }} outputs: | type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | @@ -224,7 +244,7 @@ jobs: - name: Upload digest artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digest-${{ matrix.cache_scope }} path: /tmp/digests/* @@ -251,14 +271,14 @@ jobs: steps: - name: Download digest artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digest-* merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR requires the repository ref to be lowercase. See the same # step in the `build` job above for the longer rationale. @@ -267,7 +287,7 @@ jobs: echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -275,7 +295,7 @@ jobs: - name: Compute image tags & labels id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_REF }} tags: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 739e6c79b..854ad38e0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + # Cancel in-progress runs when a new commit lands on the same PR. concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} @@ -41,11 +44,11 @@ jobs: NODE_ENV: production steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm @@ -57,7 +60,7 @@ jobs: - run: pnpm db:migrate:deploy - name: Cache Playwright browsers - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -74,7 +77,7 @@ jobs: - name: Upload Playwright HTML report on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: playwright-report/ diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml index 460140dd2..9c99ced3f 100644 --- a/.github/workflows/env-check.yml +++ b/.github/workflows/env-check.yml @@ -25,13 +25,13 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 38aa8ebb4..fa1392d93 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -6,16 +6,19 @@ on: push: branches: [main] +permissions: + contents: read + jobs: integration: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 1a9f99783..60e05283b 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -16,11 +16,11 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/no-todo-markers.yml b/.github/workflows/no-todo-markers.yml index f72470531..01d01815d 100644 --- a/.github/workflows/no-todo-markers.yml +++ b/.github/workflows/no-todo-markers.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Scan src/ for TODO/FIXME/XXX/HACK markers run: | diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 33756991c..68e067ee6 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -70,7 +70,7 @@ jobs: fi - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index eb08886d1..ebdea3088 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,11 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" @@ -28,12 +28,10 @@ jobs: - run: pnpm db:generate + # Blocking since v1.15.20 — the settings-split refactor that kept + # this warn-only is long done and the tree lints clean. - name: ESLint run: pnpm lint - # Non-blocking on the existing settings page monolith — its set-state- - # in-effect violations need the 1.4.0 settings-split refactor to fix - # cleanly. Will flip to blocking once settings/page.tsx is broken up. - continue-on-error: true - name: TypeScript strict check run: pnpm typecheck @@ -62,32 +60,37 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" - run: pnpm install --frozen-lockfile + # Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a + # production dependency fails the build. If an advisory has no + # upstream fix yet, pin an `auditConfig.ignoreCves` entry in + # package.json (pnpm's documented suppression mechanism) with a + # comment referencing the tracking issue, rather than flipping + # this step back to warn-only. - name: Audit for known vulnerabilities run: pnpm audit --prod --audit-level=high - continue-on-error: true secret-scan: name: Secret Scanning runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Gitleaks secret scan - uses: gitleaks/gitleaks-action@v3 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -97,7 +100,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Build Docker image run: docker build -t healthlog:scan . @@ -109,6 +112,12 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list sudo apt-get update && sudo apt-get install -y trivy + # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built + # image fails the job. For a finding without an upstream fix + # (e.g. a base-image CVE Alpine has not patched yet), add the CVE + # id to a `.trivyignore` file at the repository root — Trivy reads + # it automatically — one CVE per line, each with a comment naming + # the tracking issue and the re-check condition. Never suppress a + # finding that has a fixed version available; bump instead. - name: Trivy container scan run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan - continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ca58944..5e771ef1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1021,14 +1021,14 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the - 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green. -## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening +## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes. ### Added - **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced. -- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly. +- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly. - **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218. - **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217. - **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load. @@ -1050,7 +1050,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu ### Fixed -- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope. +- **Trends-row text overlapping the charts** — a specific Insights complaint from a user report. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope. - **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation. - **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete. - **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows. @@ -1067,11 +1067,11 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu - `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch. - Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs. - `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`. -- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1. +- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1. ### iOS coord -- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`. +- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements live in the internal iOS coordination notes. - The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution. ### Notes @@ -1101,7 +1101,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps ### Fixed - **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity. -- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone. +- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone. ### Tests @@ -1111,7 +1111,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps ### Notes -- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values. +- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values. - **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly. - **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD. @@ -1256,7 +1256,7 @@ Before this change, the iOS HealthKit observer would POST today's running step t ## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push) -Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap. +A user reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap. ### Added @@ -1278,7 +1278,7 @@ The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/ ## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift -Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself). +A user reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself). ### Fixed @@ -1288,12 +1288,12 @@ Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systo ## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled -Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle. +A user reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle. ### Fixed - Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings: - - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description. + - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on an affected account rendered the bare keys instead of the localised name + description. - **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body. - **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed. @@ -1307,7 +1307,7 @@ The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.te ## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper -Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship. +A user reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship. ### Fixed @@ -1327,7 +1327,7 @@ v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the p ### Fixed -- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On Marc's production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged. +- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On a large production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged. - Pool starvation that ALSO affected the supposedly-fast `?slice=summaries` request when fired concurrently — the slim slice now resolves in its own SQL budget regardless of whether the default-slice request is also in flight. ### Changed @@ -1419,7 +1419,7 @@ v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4. ### Deferred -- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`. +- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on the iOS client's build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`. - Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor. - `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene. @@ -1756,7 +1756,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader- - **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them. - **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes. - **Two dead re-exports dropped**: `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did). -- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly. +- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the gate was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly. ### Operator notes @@ -1823,11 +1823,11 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul ### Changed - **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount. -- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals. +- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future releases migrate the remaining sites away from bare literals. ### Fixed -- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries. +- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every other dashboard chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries. - **Soft-delete invisibility full-wire.** Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer. - **Six remaining insights `measurement.findMany` sites** that the earlier mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions. - **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK. @@ -1838,9 +1838,9 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul ### Performance -Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window. +Expected on large accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window. -- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain. +- **Late-mounting chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` tile burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain. - **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in. - **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path. - **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants. @@ -1942,7 +1942,7 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from those surfaces now fold the persistent rollup tier on touch, and the boot-time backfill discovery surfaces every per-(user, type, day) gap so a worker restart converges any stranded accounts. v1.4.39.2 closes -two follow-ups Marc surfaced from the post-deploy trace: the dashboard +two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard chart still painted its `< 3 daily points` empty-state on the 30-point range for accounts whose historic rollup partition had not yet been folded, and the cold-mount UX waterfall left every per-type tile @@ -2086,7 +2086,7 @@ self-heal via boot-time backfill on first reach. ### Performance -Expected on Marc-sized accounts; numbers anchored on the v1.4.38 perf +Expected on large accounts; numbers anchored on the v1.4.38 perf analysis and confirmed by the unit-test fixture suite. Live perf-verify rides the post-deploy window. @@ -2179,7 +2179,7 @@ longer poisons unrelated cards. ### Operator notes - No new migration. No env-var change. -- Expected: cold-mount `/api/analytics` on Marc-sized accounts +- Expected: cold-mount `/api/analytics` on large accounts drops from 30-75 s to ~3-5 s. ## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger @@ -2608,7 +2608,7 @@ dashboard SQL surfaced. load-shedding lands — no semantic change, just a hand-off note. - The private `dayKey` helper in `bp-in-target-fast-path` renames to `bucketDayKey` so the cross-file term lines up with the cross-tz - guard wave naming. + guard naming. - Health-score `bpInTargetPct` reuses the same prior-week query across windows instead of issuing one read per window. @@ -2664,7 +2664,7 @@ dashboard SQL surfaced. `{day}` so a11y labels announce per-weekday). - `admin`, `settings`, and `achievements` namespaces remain intentional T3 — operator-facing surface that will land in a - follow-up wave alongside the iOS launch. + follow-up release alongside the iOS launch. ### Security @@ -2733,7 +2733,7 @@ returns in a single round-trip. `count / min / max / mean` instead of ~306k bucket rows. The downstream JS aggregation is bypassed because the per-type aggregate is already shaped server-side. Cache-miss budget on - Marc-sized accounts: ~3.1 s → < 100 ms expected. + large accounts: ~3.1 s → < 100 ms expected. ### Operator notes @@ -2983,7 +2983,7 @@ double-digit MB for power users. The Insights page itself stops blocking the entire shell on `/api/insights/comprehensive`, so each section renders its own skeleton and fills in independently. -On top of the perf wave: chart slot heights pinned to kill Recharts' +On top of the performance work: chart slot heights pinned to kill Recharts' `width=-1 height=-1` warning and the matching CLS, a tri-state trend annotation contract that ends the cold-mount "more data needed" flash, medication intake history restored on the detail page, About card @@ -3726,8 +3726,7 @@ the iOS native client launches. Subsequent v1.4.x tags carry security fixes, dependency bumps, and hotfix-only corrections. New feature work is paused until the iOS app clears Apple review, at which point a v1.5.0 version-bump-only release tags the milestone. The Prisma -schema head comment and `.planning/v15-strategic-plan.md` §5 -decision-log row pin the freeze trigger in-tree. +schema head comment pins the freeze trigger in-tree. ## [1.4.33] — 2026-05-17 — Polish and reliability @@ -3922,9 +3921,9 @@ the affected surfaces. the Insights mother page now accepts both eager and `next/dynamic` spellings. -## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first wave +## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first surface -First public surface wave for the HealthKit Tier 1 metrics that +First public surface for the HealthKit Tier 1 metrics that the iOS contributor brief locked in for v1.5. The headline item is a workouts flow that lands end-to-end on the web: a list page at `/insights/workouts`, a detail page with route preview and @@ -4046,8 +4045,7 @@ sleep before the trigger step lands inside the existing ### Fixed - **Insights tab-strip blocking on mobile.** Three orthogonal - client-side fixes per - `.planning/research/v15-insights-blocking-bug.md`: + client-side fixes: - `fetchAdvisor` gets an 8 s `AbortController`; `AbortError` falls through to the existing graceful-null return path so the UI surfaces the regen CTA instead of pinning the query in @@ -4111,9 +4109,8 @@ Every change is additive on the wire. error and degrades gracefully through its existing 403 handler. v1.4.31+ iOS reads the `errorCode` to render the surface-specific empty state. -- The locked-contract entry at - `.planning/v15-ios-handoff/08-locked-contracts.md` §14 - documents the rule that the flag matrix gates BOTH +- The locked-contract coordination notes (§14) + document the rule that the flag matrix gates BOTH server-routed AND on-device assistant surfaces. Apple Foundation Models on-device Coach + Briefing flows in the v0.6.0 iOS path honour the same flag matrix as the @@ -4139,12 +4136,12 @@ standalone-first track depends on lands as §13 of `MeasurementType`. `requireAuth()` gates the route — any logged-in user passes — and `Cache-Control: public, max-age=600` enables a 10-minute edge cache. Locked per - `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. + the iOS-team coordination response, §3 R1. ### Documented - **SyncMode conflict-resolution policy.** - `.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per + The locked-contract coordination notes gain §13 per R9 of the iOS-team response doc. Hard-spec covers bulk-backfill, steady-state bidirectional sync via `(updatedAt, syncVersion)` optimistic concurrency, the 409 write-conflict + 410 Gone @@ -4188,8 +4185,7 @@ permission picker and the future Insights nav. - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is - locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12 - and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS + locked in the iOS coordination notes (§12). iOS emits one row per day per cumulative type via `HKStatisticsCollectionQuery`; the unique index collapses re-syncs idempotently, and a future PATCH-on-divergence call @@ -4898,8 +4894,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only. full filtered set, dedupes once, then slices, so `meta.total` reports the deduped count rather than the per-window `canonical.length`. -- **iOS handoff addendum.** New - `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` +- **iOS handoff addendum.** A new coordination note documents the standalone-then-pair pattern for the iOS native client. Sibling of the existing `22-offline-first-architecture.md`; same research input, neutral framing per the v1.4.27 convention @@ -5356,7 +5351,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). per-locale bodies (no `REPLY LANGUAGE` footer indirection) with the full safety-contract matrix in YAML per locale + structural refusal-probe coverage in CI (see Tests). EN + DE stay - Marc-maintained. Locale picker covers all six; signup + maintainer-curated. Locale picker covers all six; signup browser-detect maps `fr|es|it|pl` to the corresponding bundle. - **Coach — native first-party system prompts across six locales.** Coach + insights system prompts move from EN body + `REPLY @@ -5950,8 +5945,8 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). ### Deferred to v1.4.26 -See `.planning/v1426-backlog.md` for the full backlog with effort -estimates and source citations. Headline items: +The full backlog, with effort +estimates and source citations, is tracked internally. Headline items: - `User.onboardingGoals` column + server-side persistence for the goals chip-picker selection (today the picker holds the choice @@ -6510,7 +6505,7 @@ NOT NULL`) is the defence-in-depth backstop. - **Coolify image-digest auto-deploy.** Instructions for the one-time UI-toggle ("Watch image registry for new digests") - live at `.planning/coolify-auto-deploy-howto.md`. Future + live in the internal ops notes. Future releases should drop the host-side retag fallback the moment the toggle is on. - **191 maintainer-name references in `src/` source comments @@ -6526,7 +6521,7 @@ NOT NULL`) is the defence-in-depth backstop. ### Deferred to v1.4.23 -See `.planning/v1422-backlog.md` for the full carry-over list. +The full carry-over list is tracked internally. Highlights: sentinel parser malformed-enum hardening (Sr-M5); analytics-route unbounded `findMany` paging; targets-route 7-pass sparkline coalesce; `CoachDrawer key={prefill}` @@ -6828,8 +6823,7 @@ Coach extension for the new measurement types - 3 HIGH from QA — `/insights` `data?.` narrowing refactor, `/admin/api-tokens` touch-tooltip (needs Popover swap), and `/insights` hero density (folded into the v1.4.20 redesign). -- 31 MED + 16 LOW from the quality-of-life audit. Short-list at - `.planning/v1420-backlog.md`. +- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally. - `/insights` redesign with AI Coach — separate roadmap, design handoff at `~/Downloads/design_handoff_insights_redesign`. @@ -6901,9 +6895,9 @@ Coach extension for the new measurement types `messages/de.json` are still statically imported into the client bundle, so a determined user can `Cmd-F` for the hidden strings in `_next/static/chunks/*.js`. Needs a build-time strip or reversible - obfuscation. Tracked in `.planning/v1419-backlog.md`. -- See `.planning/v1419-backlog.md` for the short-list and - `.planning/v15-backlog.md` for the strategic v1.5 items. + obfuscation. Tracked internally. +- The short-list and the strategic v1.5 items are tracked + internally. ## [1.4.17] — 2026-05-10 diff --git a/Dockerfile b/Dockerfile index 3dc224e2d..a883e6c82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,18 @@ WORKDIR /app ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +# Short Git SHA of the release commit, same workflow source as the +# version arg above. Changes exactly when the source changes, so it +# adds no cache churn beyond what the COPY below already causes. +# /api/version surfaces it as `buildSha` for deploy verification +# (docs/ops/deploy.md). The built-at timestamp is intentionally NOT +# set in this stage: it differs on every run and would bust the +# `pnpm build` layer cache even for content-identical rebuilds — the +# runner stage below carries it instead (the route reads process.env +# at request time, not at bundle time). +ARG NEXT_PUBLIC_APP_BUILD_SHA +ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA + COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -54,6 +66,13 @@ ENV TZ=Europe/Berlin ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +# Short Git SHA the image was built from — /api/version returns it as +# `buildSha` so an operator can verify which commit a running `:latest` +# container actually carries (the deploy runbook checks it after every +# deploy). Provided by docker-publish.yml alongside the version arg. +ARG NEXT_PUBLIC_APP_BUILD_SHA +ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA + RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs @@ -97,6 +116,15 @@ RUN mkdir -p /opt/prisma-cli && \ RUN mkdir -p /opt/geolite2 COPY assets/geolite2/ /opt/geolite2/ +# ISO-8601 build timestamp — /api/version returns it as `builtAt`. +# Declared this late in the stage on purpose: the value differs on +# every CI run, and every instruction after an ARG shares its cache +# fate. Down here the only layers it invalidates are the cheap +# entrypoint COPY/chmod below; the npm-install layers above stay +# cache-warm across rebuilds of the same release. +ARG NEXT_PUBLIC_APP_BUILT_AT +ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT + # Entrypoint script (runs migrations, then starts app) COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 1f382c67a..f6c68cbb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: image: ghcr.io/mbombeck/healthlog:latest pull_policy: always container_name: healthlog-app + # The app runs as the unprivileged `nextjs` user and never needs a + # setuid/setgid escalation; blocking privilege gain caps what a + # compromised process inside the container can do. + security_opt: + - no-new-privileges:true ports: - "3000:3000" environment: @@ -125,8 +130,16 @@ services: # restart: unless-stopped db: - image: postgres:16-alpine + # Pinned to the concrete minor instead of the floating `16-alpine` + # alias so a database update is always a deliberate, reviewable + # change (at pin time `16-alpine` resolved to this same digest, so + # existing deployments see no image change). Minor releases share + # the on-disk format — bumping this pin is safe without a dump / + # restore; a major-version bump (17+) is not. + image: postgres:16.14-alpine container_name: healthlog-db + security_opt: + - no-new-privileges:true environment: POSTGRES_USER: healthlog POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" diff --git a/src/lib/auth/__tests__/hmac.test.ts b/src/lib/auth/__tests__/hmac.test.ts index 7852f9ed3..720f1bd0b 100644 --- a/src/lib/auth/__tests__/hmac.test.ts +++ b/src/lib/auth/__tests__/hmac.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { verifyHmacSignature, hashToken } from "../hmac"; import { createHmac } from "node:crypto"; -const TEST_HMAC_KEY = "test-hmac-key-for-unit-tests"; +const TEST_HMAC_KEY = "test-hmac-key-for-unit-tests-32-chars-min"; beforeEach(() => { vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); @@ -84,4 +84,19 @@ describe("hashToken", () => { "API_TOKEN_HMAC_KEY env var must be set", ); }); + + it("throws if API_TOKEN_HMAC_KEY is shorter than 32 characters", () => { + vi.stubEnv("API_TOKEN_HMAC_KEY", "short-key-31-characters-xxxxxxx"); + expect(() => hashToken("test")).toThrow( + "API_TOKEN_HMAC_KEY must be at least 32 characters, got 31", + ); + }); + + it("accepts a key of exactly 32 characters", () => { + const key32 = "exactly-32-characters-long-key-x"; + vi.stubEnv("API_TOKEN_HMAC_KEY", key32); + expect(key32).toHaveLength(32); + const expected = createHmac("sha256", key32).update("test").digest("hex"); + expect(hashToken("test")).toBe(expected); + }); }); diff --git a/src/lib/auth/hmac.ts b/src/lib/auth/hmac.ts index c5a303cf5..5d625239e 100644 --- a/src/lib/auth/hmac.ts +++ b/src/lib/auth/hmac.ts @@ -28,12 +28,23 @@ export function verifyHmacSignature( /** * Hash an API token for storage (SHA-256). - * Requires API_TOKEN_HMAC_KEY env var to be set. + * Requires API_TOKEN_HMAC_KEY env var to be set and at least 32 + * characters long. Fail-closed like the encryption-key loader in + * `src/lib/crypto.ts`: a weak key throws instead of silently keying + * every Bearer-token hash off guessable material. */ +const MIN_HMAC_KEY_LENGTH = 32; + export function hashToken(token: string): string { const key = process.env.API_TOKEN_HMAC_KEY; if (!key) { throw new Error("API_TOKEN_HMAC_KEY env var must be set"); } + if (key.length < MIN_HMAC_KEY_LENGTH) { + throw new Error( + `API_TOKEN_HMAC_KEY must be at least ${MIN_HMAC_KEY_LENGTH} characters, got ${key.length}. ` + + "Generate a strong key with `openssl rand -hex 32` and update the environment before restarting.", + ); + } return createHmac("sha256", key).update(token).digest("hex"); } From 0cf9bc9348cedc1126e1f78e97292171faf882de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:39:55 +0200 Subject: [PATCH 09/21] feat(medications): attribute an off-schedule intake to its slot, visibly and reversibly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An off-schedule row now tells the user when the dose had been due, and its row menu offers to attribute it to that slot when the slot is still unserved. A pinned intake is a deliberate decision with a visible badge, it always counts as taken late — never on time — and the pin can be released again. The read model binds pinned rows by their slot anchor so a pin outside the catch-up window survives reloads, and the suggestion respects the schedule's own windows instead of a hard-coded tolerance. --- src/app/api/medications/[id]/cadence/route.ts | 9 +- .../[id]/compliance/__tests__/route.test.ts | 4 +- .../api/medications/[id]/compliance/route.ts | 3 + .../medications/[id]/dose-history/route.ts | 18 ++ .../[id]/intake/[eventId]/route.ts | 40 +++- src/app/api/medications/[id]/intake/route.ts | 10 + src/app/api/medications/intake/bulk/route.ts | 8 + .../medications/dose-history-ledger.tsx | 178 ++++++++++++++++- .../analytics/__tests__/compliance.test.ts | 52 +++++ src/lib/analytics/compliance.ts | 11 ++ .../scheduling/__tests__/dose-history.test.ts | 185 ++++++++++++++++++ src/lib/medications/scheduling/cadence.ts | 6 + src/lib/medications/scheduling/compliance.ts | 2 + .../medications/scheduling/dose-history.ts | 151 +++++++++++++- src/lib/medications/scheduling/slot-upsert.ts | 16 ++ .../__tests__/notification-prefs.test.ts | 8 + src/lib/validations/about-me.ts | 25 +++ src/lib/validations/auth.ts | 7 + src/lib/validations/invite.ts | 31 +++ src/lib/validations/medication.ts | 10 +- src/lib/validations/notification-prefs.ts | 45 +++++ 21 files changed, 802 insertions(+), 17 deletions(-) create mode 100644 src/lib/validations/about-me.ts create mode 100644 src/lib/validations/invite.ts diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 708bba268..91911c24b 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -86,7 +86,14 @@ export const GET = apiHandler( deletedAt: null, scheduledFor: { gte: from }, }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + // v1.15.20 — pinned takes bind by anchor in the chip tally so the + // chips agree with the % + the history on a "zugeordnet" dose. + attributionSource: true, + }, orderBy: { scheduledFor: "asc" }, }); diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 142293b92..86dcc6585 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -458,12 +458,14 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { expect(gte).toBeInstanceOf(Date); const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000; expect((gte as Date).getTime()).toBeGreaterThan(minExpected); - // Column-bounded: only the four fields the math consumes. + // Column-bounded: only the five fields the math consumes + // (v1.15.20 adds the slot-binding provenance for pinned takes). expect(call.select).toEqual({ takenAt: true, skipped: true, scheduledFor: true, autoMissed: true, + attributionSource: true, }); }); }); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 76c1face0..905bdd490 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -142,6 +142,9 @@ async function buildCompliancePayload( // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a // miss, not a neutral skip. autoMissed: true, + // v1.15.20 — pinned takes bind by anchor in the unified ledger so the + // % agrees with the history view on a "zugeordnet" dose (taken-late). + attributionSource: true, }, }); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 9045525ea..11c47b7a2 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -77,6 +77,12 @@ interface SerializedDoseHistoryRow { at: string; timeOfDay: string | null; status: DoseHistoryRow["status"]; + /** v1.15.20 — slot served by a deliberate user pin ("zugeordnet"). */ + pinned?: boolean; + /** v1.15.20 — due-context for an ad-hoc take: the nearest slot it could + * belong to (preferring an unserved one), so the UI can show "fällig + * gewesen" and offer the pin when `filled` is false. */ + nearestSlot?: { at: string; timeOfDay: string; filled: boolean }; intake: { id: string | null; scheduledFor: string; @@ -148,6 +154,8 @@ export const GET = apiHandler( takenAt: true, skipped: true, autoMissed: true, + // v1.15.20 — USER_PIN rows bind by anchor in the read ledger. + attributionSource: true, }, }); @@ -157,6 +165,7 @@ export const GET = apiHandler( takenAt: e.takenAt, skipped: e.skipped, autoMissed: e.autoMissed, + pinned: e.attributionSource === "USER_PIN", })); const lastIntakeAt = lastNonSkippedTakenAt(mapped); @@ -226,6 +235,7 @@ export const GET = apiHandler( takenAt: e.takenAt, skipped: e.skipped, autoMissed: e.autoMissed, + pinned: e.pinned, })); const rows = reconstructDoseHistory(bands, historyIntakes, now); @@ -235,6 +245,14 @@ export const GET = apiHandler( at: row.at.toISOString(), timeOfDay: row.timeOfDay, status: row.status, + ...(row.pinned && { pinned: true }), + ...(row.nearestSlot && { + nearestSlot: { + at: row.nearestSlot.at.toISOString(), + timeOfDay: row.nearestSlot.timeOfDay, + filled: row.nearestSlot.filled, + }, + }), intake: row.intake ? { id: row.intake.id ?? null, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 22996a426..ee537ebbb 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -124,13 +124,23 @@ export const PUT = apiHandler( // leaving `scheduledFor` stale (audit HIGH-4). Precedence: // 1. an explicit `scheduledFor` in the body wins (deliberate override); // 2. else `forceSlotInstant` pins onto a named real slot (422 if it is - // not a slot of this med); - // 3. else a taken edit attributes by band (slot anchor on match, the - // take's own time on a miss → ad-hoc); - // 4. a skip / pending edit keeps the existing `scheduledFor` anchor. + // not a slot of this med) — v1.15.20: standalone too, no takenAt / + // skipped change required ("Slot zuordnen" from the ledger kebab), + // and the row is stamped USER_PIN so the read ledger binds it by + // anchor instead of degrading the off-window take back to ad-hoc; + // 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20, + // "Zuordnung lösen"): re-attribute the take by band on its takenAt + // (ad-hoc when no band matches) and reset the provenance to AUTO; + // 4. else a taken edit attributes by band (slot anchor on match, the + // take's own time on a miss → ad-hoc), provenance AUTO; + // 5. a skip / pending edit keeps the existing `scheduledFor` anchor. let resolvedScheduledFor: Date | undefined = data.scheduledFor; - if (resolvedScheduledFor === undefined && takenOrSkippedChanged) { - if (data.forceSlotInstant !== undefined) { + let attributionSource: "AUTO" | "USER_PIN" | undefined; + if (resolvedScheduledFor === undefined) { + if ( + data.forceSlotInstant !== undefined && + data.forceSlotInstant !== null + ) { const forced = await resolveForcedSlotForWrite({ userId: user.id, medicationId: id, @@ -149,7 +159,12 @@ export const PUT = apiHandler( ); } resolvedScheduledFor = forced; - } else if (nextTakenAt !== null && !nextSkipped) { + attributionSource = "USER_PIN"; + } else if ( + (data.forceSlotInstant === null || takenOrSkippedChanged) && + nextTakenAt !== null && + !nextSkipped + ) { const attribution = await resolveSlotForWriteByBand({ userId: user.id, medicationId: id, @@ -158,6 +173,11 @@ export const PUT = apiHandler( }); // band.at on match; the take's own time on a miss (ad-hoc). resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt; + attributionSource = "AUTO"; + } else if (data.forceSlotInstant === null) { + // Unpin on a row that is not a confirmed take (skip / pending): + // nothing to re-attribute, but the provenance still resets. + attributionSource = "AUTO"; } // skip / pending edits leave `scheduledFor` on the existing anchor. } @@ -175,6 +195,11 @@ export const PUT = apiHandler( data: { ...(data.takenAt !== undefined && { takenAt: data.takenAt }), ...(data.skipped !== undefined && { skipped: data.skipped }), + // v1.15.20 — binding provenance: pin / unpin / band re-attribution + // stamps it even when the anchor itself did not move (e.g. a pin + // onto the slot the row already sits on, or an unpin whose band + // re-attribution lands on the same anchor). + ...(attributionSource !== undefined && { attributionSource }), // v1.7.0 sync — bump the reconciliation counter on every // server-side mutation so the `/api/sync/changes` feed echoes a // monotonic value to paired clients. @@ -203,6 +228,7 @@ export const PUT = apiHandler( isExplicitSkip: nextSkipped, idempotencyKey: null, createSource: "WEB", + attributionSource, }); updated = applied.row; } diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index e201ebf22..a522b20bd 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -167,6 +167,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // `forceSlotInstant` pins an off-window take onto a chosen real slot // ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422. let canonicalSlot: Date | null = null; + // v1.15.20 — binding provenance for the written row: USER_PIN on the + // forced "diesem Slot zuordnen" path, AUTO when band attribution decided. + // Skips carry no binding decision (undefined → column untouched/default). + let attributionSource: "AUTO" | "USER_PIN" | undefined; if (skipped) { canonicalSlot = await resolveSlotInstantForWrite({ userId: user.id, @@ -194,6 +198,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { { errorCode: "medications.intake.force_slot.invalid" }, ); } + attributionSource = "USER_PIN"; } else { // A non-skip write on this route always carries a `takenAt` (defaulted to // now), so `resolvedTakenAt` is non-null here; the fallback only guards the @@ -205,6 +210,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { takenAt: resolvedTakenAt ?? incomingScheduledFor, }); canonicalSlot = attribution.slotInstant; + // A band decision (slot or ad-hoc) is an AUTO binding; it also resets a + // stale USER_PIN when this write converges onto a previously-pinned row. + attributionSource = "AUTO"; } // Idempotency check (explicit key or server-side dedup window) @@ -263,6 +271,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.8.5 — resolved + validated site (null unless a tracking-on // injection taken write supplied an allowed site). injectionSite: resolvedInjectionSite, + attributionSource, }); event = applied.row; consumedTransition = applied.consumedTransition; @@ -309,6 +318,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { idempotencyKey: idempotencyKey ?? null, createSource: "WEB", injectionSite: resolvedInjectionSite, + attributionSource, }); event = applied.row; consumedTransition = applied.consumedTransition; diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index a7f191f08..e95a7a504 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -260,6 +260,10 @@ async function postBulk(request: NextRequest): Promise { // attribute by, so they keep the canonical anchor snap that binds // them to the projector/worker-minted slot row. let canonicalSlot: Date | null; + // v1.15.20 — binding provenance: USER_PIN on the forced path, AUTO on + // a band decision. Pending echoes / skips carry no decision + // (undefined → the upsert leaves an existing USER_PIN untouched). + let attributionSource: "AUTO" | "USER_PIN" | undefined; if (isExplicitTaken && entry.forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, @@ -276,6 +280,7 @@ async function postBulk(request: NextRequest): Promise { results.push({ index: i, status: "skipped", reason }); continue; } + attributionSource = "USER_PIN"; } else if (isExplicitTaken) { const attribution = await resolveSlotForWriteByBand({ userId: user.id, @@ -284,6 +289,7 @@ async function postBulk(request: NextRequest): Promise { takenAt: entry.takenAt as Date, }); canonicalSlot = attribution.slotInstant; + attributionSource = "AUTO"; } else { // v1.8.2 — source-agnostic anchor snap for pending echoes + skips. // iOS posts the reminder actions here (source API); without this @@ -356,6 +362,7 @@ async function postBulk(request: NextRequest): Promise { idempotencyKey: entry.idempotencyKey ?? null, createSource: "API", injectionSite: resolvedInjectionSite, + attributionSource, }); if (applied.noDowngradeNoOp) { // C2 — pending echo onto an already-actioned slot. Report it as a @@ -425,6 +432,7 @@ async function postBulk(request: NextRequest): Promise { idempotencyKey: entry.idempotencyKey ?? null, createSource: "API", injectionSite: resolvedInjectionSite, + attributionSource, }); if (applied.noDowngradeNoOp) { duplicates += 1; diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index c0de3fc59..d7bbf9d03 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -23,7 +23,13 @@ * - a header "+ Eintrag" opens an add dialog scoped to this medication * (incl. backdated / off-schedule); an off-window take surfaces the * "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via - * `forceSlotInstant` rather than orphaning it to an ad-hoc row. + * `forceSlotInstant` rather than orphaning it to an ad-hoc row; + * - v1.15.20: an ad-hoc row shows WHEN the dose would have been due + * (`nearestSlot` subline) and — when that slot is still unserved — a + * kebab "Slot … zuordnen" action that pins the take onto it after a + * confirm that names the consequence ("zählt als verspätet"). A pinned + * slot row carries a quiet "zugeordnet" badge and the inverse + * "Zuordnung lösen" kebab action (`forceSlotInstant: null`). * * A compact took / skipped history chart sits at the top — the ledger already * carries the counts. The card body never tints by status (removed earlier); @@ -37,12 +43,14 @@ import { AlertTriangle, Check, CircleCheck, + Link2, Loader2, MoreVertical, Pencil, Plus, SkipForward, Trash2, + Unlink, } from "lucide-react"; import { toast } from "sonner"; @@ -79,6 +87,7 @@ import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialo import { applyOptimisticSlotMark, complianceFromLedger, + formatSlotDelta, groupLedgerByDay, isSlotActionable, type LedgerPayload, @@ -154,6 +163,12 @@ export function DoseHistoryLedger({ } | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [marking, setMarking] = useState(null); + // v1.15.20 — the pending "Slot zuordnen" confirm: which intake to pin + // onto which slot. The confirm names the consequence (counts as late). + const [pendingPin, setPendingPin] = useState<{ + eventId: string; + slotAt: string; + } | null>(null); // Stable window captured once at mount (a lazy state initializer keeps the // impure `Date.now()` out of render) so the query key + the `from`/`to` @@ -339,6 +354,45 @@ export function DoseHistoryLedger({ [medicationId, queryClient, queryKey, t], ); + /** + * v1.15.20 — pin an ad-hoc take onto a scheduled slot (`forceSlotInstant`) + * or release a pinned binding (`forceSlotInstant: null` → the server + * re-attributes by band, usually back to ad-hoc). Same PUT contract the + * edit dialog uses; the authoritative refetch re-renders the moved row. + */ + const setAttribution = useCallback( + async (eventId: string, slotAtIso: string | null) => { + try { + const res = await fetch( + `/api/medications/${medicationId}/intake/${eventId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ forceSlotInstant: slotAtIso }), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.intake.edit.failed")); + return; + } + toast.success( + t( + slotAtIso + ? "medications.detail.verlauf.pin.pinnedToast" + : "medications.detail.verlauf.pin.unpinnedToast", + ), + ); + await invalidateKeys(queryClient, [ + ...medicationDependentKeys, + queryKey, + ]); + } catch { + toast.error(t("medications.detail.intake.edit.failed")); + } + }, + [medicationId, queryClient, queryKey, t], + ); + async function confirmRowDelete() { if (!pendingDeleteId) return; const id = pendingDeleteId; @@ -430,6 +484,10 @@ export function DoseHistoryLedger({ onToggleSkipped={toggleIntakeSkipped} onEdit={(ev) => setEditingEvent(ev)} onDelete={(id) => setPendingDeleteId(id)} + onPin={(eventId, slotAt) => + setPendingPin({ eventId, slotAt }) + } + onUnpin={(eventId) => void setAttribution(eventId, null)} /> ))} @@ -459,6 +517,18 @@ export function DoseHistoryLedger({ onCancel={() => setPendingDeleteId(null)} /> )} + + {pendingPin && ( + { + const pin = pendingPin; + setPendingPin(null); + void setAttribution(pin.eventId, pin.slotAt); + }} + onCancel={() => setPendingPin(null)} + /> + )} ); } @@ -482,6 +552,8 @@ function LedgerRowItem({ onToggleSkipped, onEdit, onDelete, + onPin, + onUnpin, }: { row: LedgerRow; marking: string | null; @@ -499,6 +571,10 @@ function LedgerRowItem({ scheduledFor: string | null; }) => void; onDelete: (id: string) => void; + /** Open the pin confirm for an ad-hoc take onto the named slot (ISO). */ + onPin: (eventId: string, slotAt: string) => void; + /** Release a pinned binding (server re-attributes by band). */ + onUnpin: (eventId: string) => void; }) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -524,6 +600,16 @@ function LedgerRowItem({ intake?.takenAt && (row.status === "taken_on_time" || row.status === "taken_late"); + // v1.15.20 — an ad-hoc take with a due-context shows WHEN the dose would + // have been due; the pin action additionally needs the slot unserved and a + // real persisted event to re-anchor. + const dueContext = + row.kind === "ad_hoc" && intake?.takenAt && row.nearestSlot + ? row.nearestSlot + : null; + const canPin = Boolean(dueContext && !dueContext.filled && intake?.id); + const canUnpin = Boolean(row.kind === "slot" && row.pinned && intake?.id); + return (
  • )} + {row.pinned && ( + + {t("medications.detail.verlauf.pin.pinnedTag")} + + )} {showTakeDetail && (

    @@ -557,6 +652,17 @@ function LedgerRowItem({ })}

    )} + {dueContext && ( +

    + {t("medications.detail.verlauf.pin.dueContext", { + time: fmt.time(new Date(dueContext.at)), + delta: formatSlotDelta(intake!.takenAt!, dueContext.at), + })} +

    + )}
    @@ -624,6 +730,28 @@ function LedgerRowItem({ {t("medications.detail.verlauf.markSkipped")} )} + {canPin && ( + + onPin(intake!.id as string, dueContext!.at) + } + data-slot="ledger-pin" + > + + )} + {canUnpin && ( + onUnpin(intake!.id as string)} + data-slot="ledger-unpin" + > + + )} {intake?.id && ( <> void; + onCancel: () => void; +}) { + const { t } = useTranslations(); + return ( + !open && onCancel()}> + + + + {t("medications.detail.verlauf.pin.confirmTitle", { + time: slotLabel, + })} + + + {t("medications.detail.verlauf.pin.confirmBody")} + + + + + {t("medications.detail.verlauf.pin.confirmCancel")} + + { + e.preventDefault(); + onConfirm(); + }} + data-slot="ledger-pin-confirm-action" + > + {t("medications.detail.verlauf.pin.confirmAction")} + + + + + ); +} + /** Lightweight single-row delete confirm — the ledger does not need the full * bulk-delete state machine, just a guard. */ function DeleteRowConfirm({ diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 11b25c5d1..de1deea9f 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -2582,6 +2582,58 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { expect(tally.rate).toBe(100); }); + // v1.15.20 — a USER_PIN take binds by its slot anchor, so a deliberate + // "diesem Slot zuordnen" on an off-window take serves the slot as a LATE + // dose: it enters the numerator (taken), never the on-time count, and the + // slot stops reading missed. The same instants WITHOUT the pin read + // ad-hoc + missed — the pin is the only thing that may move them. + it("a pinned off-window take serves its slot as late — no on-time gain", () => { + const pinned = [ + { + scheduledFor: at(7, 0), + takenAt: at(13, 0), // far past the 07:00 band's tail + skipped: false, + attributionSource: "USER_PIN" as const, + }, + { scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + pinned, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(2); + expect(tally.takenOnTime).toBe(1); // only the 19:00 dose + expect(tally.takenLate).toBe(1); // the pin counts late, never on-time + expect(tally.missed).toBe(0); + expect(tally.adHoc).toBe(0); + expect(tally.rate).toBe(100); + + // Control: the identical instants without the pin stay ad-hoc and the + // 07:00 slot reads missed — AUTO attribution is untouched. + const unpinned = pinned.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + })); + const auto = tallyComplianceFromLedger( + unpinned, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(auto.taken).toBe(1); + expect(auto.takenLate).toBe(0); + expect(auto.missed).toBe(1); + expect(auto.adHoc).toBe(1); + expect(auto.rate).toBe(50); + }); + // v1.15.19 — intraday pending rows. The today-projector mints pending // rows (takenAt = null, not skipped, not auto-missed) for every slot of // the day up front; from slot time onward the old ledger read them as diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 36d1e1a69..1e62043ce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -61,6 +61,14 @@ interface IntakeEvent { * row. */ autoMissed?: boolean; + /** + * v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately + * pinned this off-window take onto its `scheduledFor` slot; the unified + * ledger then binds it by anchor (taken-late, never on-time-washed) + * instead of degrading it to ad-hoc when the takenAt sits outside the + * band tail. Optional so legacy callers / fixtures default to AUTO. + */ + attributionSource?: "AUTO" | "USER_PIN"; } export type IntakeTimingClass = @@ -1283,6 +1291,9 @@ export function buildComplianceLedgerRows( takenAt: e.takenAt, skipped: e.skipped, autoMissed: e.autoMissed ?? false, + // v1.15.20 — a pinned take binds by anchor and tallies as taken-late + // (slot served, no on-time gain) instead of ad_hoc + missed. + pinned: e.attributionSource === "USER_PIN", })); return reconstructDoseHistory(bands, intakes, now); diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 0b52efba5..c635bcaad 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest"; import { buildSlotBands, type SlotWindowInput } from "../attribution"; import { reconstructDoseHistory, + suggestNearestSlot, type HistoryIntake, } from "../dose-history"; import { localHmAsUtc } from "@/lib/timezone"; @@ -231,6 +232,190 @@ describe("reconstructDoseHistory", () => { }); }); + // v1.15.20 — deliberate user pins ("diesem Slot zuordnen"). A USER_PIN row + // binds by its stored `scheduledFor` anchor like a skip, NOT by takenAt + // band membership, so a pin outside the late tail stays on its slot. The + // status never flatters: taken_late unless the takenAt happens to sit + // inside the slot's own on-time band anyway. + describe("pinned takes (USER_PIN)", () => { + it("binds an off-band pinned take to its slot anchor as taken_late", () => { + // 13:00 is past the 07:00 band's miss cutoff (11:00) — unpinned it + // would be ad-hoc and the slot missed; the pin keeps it on the slot. + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(13, 0), scheduledFor: at(7, 0), pinned: true })], + nowEvening, + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_late"); + expect(morning?.pinned).toBe(true); + expect(morning?.intake?.takenAt?.toISOString()).toBe( + at(13, 0).toISOString(), + ); + expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0); + }); + + it("reads taken_on_time only when the takenAt sits inside the on-time band anyway", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(7, 30), scheduledFor: at(7, 0), pinned: true })], + nowEvening, + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_on_time"); + expect(morning?.pinned).toBe(true); + }); + + it("a pin inside the late tail reads taken_late, never flattered", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(9, 0), scheduledFor: at(7, 0), pinned: true })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( + "taken_late", + ); + }); + + it("a deliberate pin claims the slot before a band-attributed take", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "auto", takenAt: at(7, 0), scheduledFor: at(7, 0) }), + intake({ + id: "pin", + takenAt: at(13, 0), + scheduledFor: at(7, 0), + pinned: true, + }), + ], + nowEvening, + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.intake?.id).toBe("pin"); + expect(morning?.status).toBe("taken_late"); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc.map((r) => r.intake?.id)).toEqual(["auto"]); + }); + + it("falls through to ad-hoc when the pinned slot is already claimed by a skip", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "skip", skipped: true, scheduledFor: at(7, 0) }), + intake({ + id: "pin", + takenAt: at(13, 0), + scheduledFor: at(7, 0), + pinned: true, + }), + ], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped"); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc.map((r) => r.intake?.id)).toEqual(["pin"]); + }); + + it("falls through to ad-hoc when the pinned anchor matches no band (schedule changed)", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ + takenAt: at(15, 30), + scheduledFor: at(15, 0), + pinned: true, + }), + ], + nowEvening, + ); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc).toHaveLength(1); + expect(adhoc[0].at.toISOString()).toBe(at(15, 30).toISOString()); + }); + }); + + // v1.15.20 — the due-context an ad-hoc take carries. With the test + // geometry the 07:00 band runs 06:00–11:00 (on-time 06:00–08:00, 180min + // tail), so its suggestion zone extends to 12:30 (tail + 50 %). + describe("ad-hoc nearestSlot due-context", () => { + it("an ad-hoc take inside an unserved slot's suggestion zone offers the pin", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })], + nowEvening, + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00"); + expect(adhoc?.nearestSlot?.at.toISOString()).toBe(at(7, 0).toISOString()); + expect(adhoc?.nearestSlot?.filled).toBe(false); + }); + + it("a take past the suggestion zone keeps the context but reads filled (no pin offer)", () => { + // 12:45 > 12:30 — outside the zone even though the slot is unserved. + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(12, 45), scheduledFor: at(7, 0) })], + nowEvening, + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00"); + expect(adhoc?.nearestSlot?.filled).toBe(true); + }); + + it("a second take near an already-served slot reads filled", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "a", takenAt: at(7, 0), scheduledFor: at(7, 0) }), + intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }), + ], + nowEvening, + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc?.intake?.id).toBe("b"); + expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00"); + expect(adhoc?.nearestSlot?.filled).toBe(true); + }); + + it("an orphaned ad-hoc skip carries no due-context", () => { + const morningOnly = bands.filter((b) => b.timeOfDay === "07:00"); + const rows = reconstructDoseHistory( + morningOnly, + [intake({ skipped: true, scheduledFor: at(19, 0) })], + at(14, 0), + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc).toBeDefined(); + expect(adhoc?.nearestSlot).toBeUndefined(); + }); + + it("suggestNearestSlot caps the zone at the next slot's on-time start", () => { + // 07:00's tail+50 % would reach 12:30, but the 12:00 slot's on-time + // window opens at 11:30 — the zones must stay disjoint. + const tight = buildSlotBands([ + pointSlot(at(7, 0), "07:00"), + { + at: at(12, 0), + timeOfDay: "12:00", + onTimeStart: at(11, 30), + onTimeEnd: at(12, 30), + lateGraceMs: 180 * MIN, + }, + ]); + const before = suggestNearestSlot(at(11, 15), tight, () => false); + expect(before?.timeOfDay).toBe("07:00"); + expect(before?.filled).toBe(false); + const after = suggestNearestSlot(at(11, 45), tight, () => false); + expect(after?.timeOfDay).toBe("12:00"); + expect(after?.filled).toBe(false); + }); + + it("suggestNearestSlot returns null without bands", () => { + expect(suggestNearestSlot(at(9, 0), [], () => false)).toBeNull(); + }); + }); + it("returns rows in chronological order (slots + ad-hoc interleaved)", () => { const rows = reconstructDoseHistory( bands, diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index e608c23b7..911847de3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -177,6 +177,12 @@ export interface IntakeEventLike { * fixture that omits it keeps the user-skip-vs-taken-vs-missed contract. */ autoMissed?: boolean; + /** + * v1.15.20 — slot-binding provenance (`USER_PIN` = deliberate user pin). + * Read by the band-ledger chip tally so a pinned take binds by anchor; + * optional so legacy callers default to AUTO. + */ + attributionSource?: "AUTO" | "USER_PIN"; } /** One slot the schedule expected the user to dose. */ diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index cc8e9ccff..cdb602d11 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -255,6 +255,8 @@ function ledgerChipCounts( takenAt: e.takenAt, skipped: e.skipped, autoMissed: e.autoMissed ?? false, + // v1.15.20 — pinned takes bind by anchor (taken-late, slot served). + pinned: e.attributionSource === "USER_PIN", })); const rows = reconstructDoseHistory(bands, intakes, asOf); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 20f53a2a7..2a8cac8fb 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -18,6 +18,12 @@ * a slot outside the queried window, not a user action); * - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`; * inside a band → that slot (on-time / late), outside every band → ad-hoc; + * - a TAKEN intake the user PINNED onto a slot (`pinned`, v1.15.20 — + * `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like + * a skip, NOT by takenAt-band membership, so a pin outside the late tail + * never degrades back to ad-hoc. Status is `taken_late` unless the + * takenAt happens to sit inside the slot's on-time band — a pin can + * never flatter the timing; * - each slot is claimed by at most one intake (first/best wins); extra * intakes near a filled slot fall through to ad-hoc. * @@ -39,6 +45,12 @@ export interface HistoryIntake { skipped: boolean; /** Cron-marked forgotten dose (counts as missed, not skipped). */ autoMissed?: boolean; + /** + * v1.15.20 — `attributionSource === "USER_PIN"`: the user deliberately + * pinned this take onto its `scheduledFor` slot. Binds by anchor, not by + * takenAt band. Optional so legacy callers / fixtures default to AUTO. + */ + pinned?: boolean; } export type DoseHistoryStatus = @@ -49,6 +61,25 @@ export type DoseHistoryStatus = | "upcoming" | "ad_hoc"; +/** + * v1.15.20 — the due-context an ad-hoc row carries: the nearest scheduled + * slot the take COULD belong to, so the UI can show "fällig gewesen: …" and + * offer "diesem Slot zuordnen" when the slot is still unserved. + */ +export interface NearestSlotContext { + /** The slot's canonical anchor instant. */ + at: Date; + /** The slot's "HH:mm" label. */ + timeOfDay: string; + /** + * True when the slot cannot be offered for pinning: it is already served + * by another intake (take / skip), OR the take falls outside the slot's + * suggestion window (the context is then informational only — "fällig + * gewesen" renders, the pin action does not). + */ + filled: boolean; +} + export interface DoseHistoryRow { /** A scheduled slot, or a standalone off-schedule intake. */ kind: "slot" | "ad_hoc"; @@ -59,6 +90,19 @@ export interface DoseHistoryRow { status: DoseHistoryStatus; /** The intake attributed to this row, if any. */ intake: HistoryIntake | null; + /** + * v1.15.20 — true when this slot row is served by a USER_PIN intake (a + * deliberate "diesem Slot zuordnen" decision). The UI badges it + * "zugeordnet" and offers "Zuordnung lösen". + */ + pinned?: boolean; + /** + * v1.15.20 — for an ad-hoc TAKE only: the nearest scheduled slot in the + * window (preferring an unserved slot whose suggestion band contains the + * take). Absent when the medication has no expected slots in the window + * or the row is an orphaned skip. + */ + nearestSlot?: NearestSlotContext; } /** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */ @@ -76,16 +120,19 @@ export function reconstructDoseHistory( // Per-band claim: the intake attributed to it + the resolved status. const claim = new Map< SlotBand, - { intake: HistoryIntake; status: DoseHistoryStatus } + { intake: HistoryIntake; status: DoseHistoryStatus; pinned?: boolean } >(); const adHoc: HistoryIntake[] = []; // Partition: anchored rows (skip / auto-missed / pending — no takenAt) bind - // by scheduledFor; taken rows attribute by real takenAt band membership. - // Process anchored first so a deliberate skip/miss owns its slot before a - // stray take could. + // by scheduledFor; PINNED takes (v1.15.20) also bind by scheduledFor (the + // pin IS the binding decision); unpinned taken rows attribute by real + // takenAt band membership. Process anchored first so a deliberate + // skip/miss owns its slot before a stray take could, then pins (a + // deliberate decision beats band proximity), then band-attributed takes. const anchored = intakes.filter((i) => i.takenAt === null); - const taken = intakes.filter((i) => i.takenAt !== null); + const pinnedTaken = intakes.filter((i) => i.takenAt !== null && i.pinned); + const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned); for (const i of anchored) { const band = nearestAnchorBand(i.scheduledFor, bands); @@ -114,6 +161,28 @@ export function reconstructDoseHistory( // window; emitting the placeholder would fabricate a phantom ad-hoc row. } + // v1.15.20 — pinned takes bind by their stored slot anchor, NOT by + // takenAt-band membership: the whole point of a pin is that the take sits + // outside (or past the tail of) the band it belongs to. Status never + // flatters: taken_late, unless the takenAt happens to sit inside the + // slot's own on-time band anyway. A pin whose slot is gone (schedule + // changed) or already claimed falls through to ad-hoc so nothing vanishes. + for (const i of pinnedTaken) { + const band = nearestAnchorBand(i.scheduledFor, bands); + if (band && !claim.has(band)) { + const t = (i.takenAt as Date).getTime(); + const onTime = + t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime(); + claim.set(band, { + intake: i, + status: onTime ? "taken_on_time" : "taken_late", + pinned: true, + }); + } else { + adHoc.push(i); + } + } + for (const i of taken) { const matched = attributeIntakeToSlot(i.takenAt as Date, bands); if (matched && !claim.has(matched.band)) { @@ -135,6 +204,7 @@ export function reconstructDoseHistory( timeOfDay: band.timeOfDay, status: c.status, intake: c.intake, + ...(c.pinned && { pinned: true }), }; } // Unfilled slot: missed only once the miss cutoff (the late tail's end) @@ -152,12 +222,21 @@ export function reconstructDoseHistory( }); for (const i of adHoc) { + // v1.15.20 — an ad-hoc TAKE carries its due-context: the nearest slot it + // could belong to, so the UI can show when the dose would have been due + // and offer "diesem Slot zuordnen" when that slot is still unserved. + // Orphaned skips (no takenAt) carry no take to attribute, so no context. + const nearestSlot = + i.takenAt !== null + ? suggestNearestSlot(i.takenAt, bands, (band) => claim.has(band)) + : null; rows.push({ kind: "ad_hoc", at: i.takenAt ?? i.scheduledFor, timeOfDay: null, status: "ad_hoc", intake: i, + ...(nearestSlot && { nearestSlot }), }); } @@ -165,6 +244,68 @@ export function reconstructDoseHistory( return rows; } +/** + * v1.15.20 — the slot an ad-hoc take most plausibly belongs to. + * + * Preference order: + * 1. an UNSERVED slot whose suggestion band contains the take — the band is + * the slot's capture zone (`onTimeStart`‥`overdueEnd`) extended past the + * tail by 50 % of the tail's length, capped at the next slot's + * `onTimeStart` so two adjacent slots' suggestion zones stay disjoint. + * Nearest anchor wins among multiple matches; + * 2. otherwise the nearest slot anchor overall (served or not) — the UI + * still shows the "fällig gewesen" context, it just can't offer the pin. + * + * Pure; `isFilled` reports whether a band is already claimed by an intake. + */ +export function suggestNearestSlot( + takenAt: Date, + bands: SlotBand[], + isFilled: (band: SlotBand) => boolean, +): NearestSlotContext | null { + if (bands.length === 0) return null; + const t = takenAt.getTime(); + const sorted = [...bands].sort((a, b) => a.at.getTime() - b.at.getTime()); + + let bestSuggest: { band: SlotBand; dist: number } | null = null; + let bestAny: { band: SlotBand; dist: number } | null = null; + + for (let i = 0; i < sorted.length; i++) { + const band = sorted[i]; + const dist = Math.abs(t - band.at.getTime()); + if (bestAny === null || dist < bestAny.dist) { + bestAny = { band, dist }; + } + + if (isFilled(band)) continue; + // Suggestion zone: the capture band plus half the late tail again, + // capped at the following slot's on-time start (disjointness mirrors + // `buildSlotBands`' overdue cap). + const tailMs = band.overdueEnd.getTime() - band.onTimeEnd.getTime(); + let suggestEnd = band.overdueEnd.getTime() + tailMs * 0.5; + const next = sorted[i + 1]; + if (next) suggestEnd = Math.min(suggestEnd, next.onTimeStart.getTime()); + if (t >= band.onTimeStart.getTime() && t <= suggestEnd) { + if (bestSuggest === null || dist < bestSuggest.dist) { + bestSuggest = { band, dist }; + } + } + } + + const pick = bestSuggest ?? bestAny; + if (!pick) return null; + return { + at: pick.band.at, + timeOfDay: pick.band.timeOfDay, + // `filled` gates the pin offer. Only a preference-1 pick (an UNSERVED + // slot whose suggestion window contains the take) is pinnable; the + // nearest-anchor fallback is due-context only, so it reads filled even + // when the slot itself is unserved — the pin never reaches across the + // suggestion cap. + filled: pick !== bestSuggest, + }; +} + /** The band whose anchor is within epsilon of `instant`, nearest wins. */ function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null { const t = instant.getTime(); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index a7c95552a..fa31ea53b 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -64,6 +64,14 @@ export interface ApplyCanonicalSlotWriteInput { * upsert, so this is trusted here. */ injectionSite?: InjectionSiteKey | null; + /** + * v1.15.20 — slot-binding provenance to stamp on the row. `USER_PIN` on + * the forced "diesem Slot zuordnen" paths, `AUTO` when a write + * (re-)attributes by window band. Omitted → leave the column untouched on + * an update / default AUTO on a create (pending echoes and skips never + * carry a binding decision). + */ + attributionSource?: "AUTO" | "USER_PIN"; } export interface ApplyCanonicalSlotWriteResult { @@ -165,6 +173,7 @@ export async function applyCanonicalSlotWrite( idempotencyKey, createSource, injectionSite = null, + attributionSource, } = input; const rows = await findSlotRows(client, userId, medicationId, canonicalSlot); @@ -193,6 +202,8 @@ export async function applyCanonicalSlotWrite( // v1.8.5 — site only when the route resolved one (taken // injection, tracking on, validated allowed). ...(injectionSite !== null && { injectionSite }), + // v1.15.20 — binding provenance; absent → schema default AUTO. + ...(attributionSource !== undefined && { attributionSource }), }, select: SLOT_ROW_SELECT, })) as SlotIntakeRow; @@ -238,6 +249,7 @@ async function applyToExisting( isExplicitSkip, idempotencyKey, injectionSite = null, + attributionSource, } = input; const existingActioned = existing.takenAt !== null || existing.skipped; @@ -281,6 +293,10 @@ async function applyToExisting( // one (taken injection, tracking on, validated). Never clear a // previously-recorded site with a null on an idempotent re-post. ...(injectionSite !== null && { injectionSite }), + // v1.15.20 — binding provenance: set only when this write carries a + // decision (pin / band re-attribution). A pending echo or skip never + // overwrites a recorded USER_PIN. + ...(attributionSource !== undefined && { attributionSource }), }, select: SLOT_ROW_SELECT, })) as SlotIntakeRow; diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index f199b12d3..fdc570eb3 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -91,6 +91,7 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -99,6 +100,7 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -109,6 +111,7 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -135,6 +138,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -149,6 +153,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -160,6 +165,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -171,6 +177,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -183,6 +190,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 8 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); diff --git a/src/lib/validations/about-me.ts b/src/lib/validations/about-me.ts new file mode 100644 index 000000000..052397948 --- /dev/null +++ b/src/lib/validations/about-me.ts @@ -0,0 +1,25 @@ +/** + * v1.15.20 — request schema for `PUT /api/coach/about-me`. + * + * Lives outside the route file so the OpenAPI registry + * (`src/lib/openapi/routes.ts`) can import it without touching the + * route module (route files may only export handlers + config), and + * without dragging the Prisma-backed helper module + * (`src/lib/ai/coach/about-me.ts`) into the generator script. + */ +import { z } from "zod/v4"; + +/** Hard cap enforced BEFORE encryption. */ +export const ABOUT_ME_MAX_CHARS = 4000; + +export const aboutMePutSchema = z.object({ + /** + * The user's free-text self-description. An empty / whitespace-only + * value clears the stored text. + */ + aboutMe: z + .string() + .max(ABOUT_ME_MAX_CHARS, `Maximum ${ABOUT_ME_MAX_CHARS} characters`), +}); + +export type AboutMePutInput = z.infer; diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 761ba630f..329cd2878 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -27,6 +27,13 @@ export const registerSchema = z.object({ * keep working. */ timezone: z.string().max(64).optional(), + /** + * v1.15.20 — registration invite token (`hlv_<64hex>`, 68 chars). + * Only consulted when the operator has disabled open registration: + * a valid, unexpired invite still admits the signup. Ignored when + * registration is open. + */ + inviteToken: z.string().max(80).optional(), }); export const loginPasswordSchema = z.object({ diff --git a/src/lib/validations/invite.ts b/src/lib/validations/invite.ts new file mode 100644 index 000000000..acccb5708 --- /dev/null +++ b/src/lib/validations/invite.ts @@ -0,0 +1,31 @@ +/** + * v1.15.20 — request schema for `POST /api/admin/invites`. + * + * Lives outside the route file (and outside the Prisma-backed helper + * `src/lib/auth/invite-token.ts`) so the OpenAPI registry can import it + * without dragging `@/lib/db` into the generator script — the same + * split as `src/lib/validations/about-me.ts`. + */ +import { z } from "zod/v4"; + +/** Hard ceiling on the admin-selectable invite lifetime (days). */ +export const INVITE_MAX_TTL_DAYS = 30; + +/** Hard ceiling on multi-use invites — generous for a household / small + * practice, far below anything that would re-open registration at scale. */ +export const INVITE_MAX_USES_CAP = 50; + +export const inviteCreateSchema = z.object({ + /** Lifetime in days. The admin UI offers 7 / 14 / 30; the API accepts + * any integer 1–30 so an operator can script shorter invites. */ + expiresInDays: z + .number() + .int() + .min(1) + .max(INVITE_MAX_TTL_DAYS) + .default(7), + /** How many signups this invite admits. Default: single-use. */ + maxUses: z.number().int().min(1).max(INVITE_MAX_USES_CAP).default(1), +}); + +export type InviteCreateInput = z.infer; diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 0325b3c44..cdfb37b3d 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -730,19 +730,25 @@ export const updateIntakeEventSchema = z * take onto a chosen slot; the server validates the instant is a real * scheduled slot (422 otherwise). Absent → the edit re-runs band * attribution on the new `takenAt`. + * + * v1.15.20 — an explicit `null` UNPINS: the dose re-attributes by window + * band on its (unchanged or edited) `takenAt` and the binding provenance + * resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer + * requires `takenAt` or `skipped` to change in the same request. */ forceSlotInstant: z.iso .datetime({ offset: true }) .transform((s) => new Date(s)) + .nullable() .optional() .describe( - "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Absent re-runs the default window-band attribution on the edited `takenAt`.", + "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`.", ), }) .meta({ id: "UpdateMedicationIntakeEventRequest", description: - "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.", + "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.", }); /** diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index f42c1f183..0fbc2cf84 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -70,6 +70,20 @@ const cyclePrefsSchema = z }) .partial(); +/** + * v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive + * Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an + * opt-out surface — it only fires for users who kept the Coach enabled + * AND have a working provider), flipped off from Settings → + * Notifications. Sub-object form keeps the layout open for future + * coach-notification knobs (quiet days, trigger selection, …). + */ +const coachPrefsSchema = z + .object({ + nudgesEnabled: z.boolean(), + }) + .partial(); + /** * v1.7.0 — per-device delivery override schema for the device PATCH. * NULL clears the override (the device inherits the user-level roaming @@ -88,6 +102,7 @@ export const notificationPrefsSchema = z medication: medicationPrefsSchema, mood: moodPrefsSchema, cycle: cyclePrefsSchema, + coach: coachPrefsSchema, }) .partial(); @@ -116,6 +131,13 @@ export interface NotificationPrefs { */ clientManaged: boolean; }; + coach: { + /** + * v1.15.20 — proactive Coach nudge opt-out. Default `true`; the + * 05:15 nudge cron skips the user when `false`. + */ + nudgesEnabled: boolean; + }; } /** @@ -141,6 +163,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { cycle: { clientManaged: false, }, + coach: { + nudgesEnabled: true, + }, }; /** @@ -181,6 +206,10 @@ export function resolveNotificationPrefs( ...base.cycle, ...(incoming.cycle ?? {}), }, + coach: { + ...base.coach, + ...(incoming.coach ?? {}), + }, }); } @@ -206,6 +235,7 @@ function applyDeliveryDefaultMapping(prefs: NotificationPrefs): NotificationPref }, mood: { ...prefs.mood }, cycle: { ...prefs.cycle }, + coach: { ...prefs.coach }, }; } @@ -262,11 +292,22 @@ export function resolveMoodReminderHour(raw: unknown): number { return parseNotificationPrefs(raw).mood.reminderHour; } +/** + * v1.15.20 — cron-side helper. Returns `true` when the proactive Coach + * nudge may fire for this user (the default). Tolerates a null / + * drifted prefs row. The nudge cron layers this on top of the + * disableCoach / kill-switch / provider gates. + */ +export function resolveCoachNudgesEnabled(raw: unknown): boolean { + return parseNotificationPrefs(raw).coach.nudgesEnabled; +} + function cloneDefaults(): NotificationPrefs { return { medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, mood: { ...DEFAULT_NOTIFICATION_PREFS.mood }, cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle }, + coach: { ...DEFAULT_NOTIFICATION_PREFS.coach }, }; } @@ -286,5 +327,9 @@ function mergeOverDefaults( ...DEFAULT_NOTIFICATION_PREFS.cycle, ...(input.cycle ?? {}), }, + coach: { + ...DEFAULT_NOTIFICATION_PREFS.coach, + ...(input.coach ?? {}), + }, }); } From b108ae7318ae36b851672a7570ef1240c0e7764b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:40:37 +0200 Subject: [PATCH 10/21] feat(ux): close the feedback gaps a new user runs into first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Withings OAuth callback finally reports its outcome — connected or a readable reason — instead of dropping the user on the settings page without a word. The adherence number carries one name everywhere and an explanation right where it is shown. Demo mode announces itself with a banner instead of failing silently in English. Bug reports become reachable from the mobile menu, the about section returns to the settings navigation, charts link to the target settings they draw, integrations and source priority point at each other, and the medication wizard warns when reminders are switched on without a configured channel. The longest explainer texts shrink to two sentences. --- src/app/insights/blood-pressure/page.tsx | 1 + src/app/insights/weight/page.tsx | 1 + src/app/layout.tsx | 8 +- src/app/page.tsx | 2 + src/components/charts/health-chart.tsx | 26 +++++- .../charts/medication-compliance-chart.tsx | 4 + .../doctor-report-section-toggles.test.tsx | 4 +- .../__tests__/correlation-card.test.tsx | 6 +- .../health-score-delta-explainer.test.tsx | 4 +- src/components/layout/auth-shell.tsx | 20 ++++- src/components/layout/bottom-nav.tsx | 24 +++++- src/components/layout/demo-banner.tsx | 38 +++++++++ src/components/layout/sidebar-nav.tsx | 17 +++- .../medications/__tests__/card-parts.test.tsx | 16 ++-- .../dose-history-ledger-compute.test.ts | 28 ++++++ .../card-parts/compliance-info-tip.tsx | 63 ++++++++++++++ .../card-parts/medication-compliance-bars.tsx | 9 +- .../dose-history-ledger-compute.ts | 23 +++++ .../medications/wizard/steps/Step8Summary.tsx | 40 +++++++++ .../__tests__/settings-shell.test.tsx | 24 ++---- .../settings/integrations-section.tsx | 85 ++++++++++++++++++- .../settings/notifications-section.tsx | 5 ++ src/components/settings/settings-shell.tsx | 20 +++-- src/components/settings/sources-section.tsx | 13 +++ .../__tests__/i18n-locale-integrity.test.ts | 2 +- 25 files changed, 433 insertions(+), 50 deletions(-) create mode 100644 src/components/layout/demo-banner.tsx create mode 100644 src/components/medications/card-parts/compliance-info-tip.tsx diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 7cb330f84..f5f6d3b9a 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -160,6 +160,7 @@ export default function InsightsBlutdruckPage() { unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} + targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b31c56ede..c1b946e72 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -106,6 +106,7 @@ export default function InsightsGewichtPage() { colors={["#bd93f9"]} unit="kg" valueBands={weightBands} + targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 32757cd95..caae60d0c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -120,7 +120,13 @@ export default async function RootLayout({ - {children} + {/* `DEMO_MODE` is a server-only env var; the proxy uses it to + block mutations. Resolve it here (server component) and + thread the boolean into the client shell so the demo + banner can render without a client-side detection path. */} + + {children} + diff --git a/src/app/page.tsx b/src/app/page.tsx index 0b940eb31..682319fb9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1424,6 +1424,7 @@ export default function DashboardPage() { colors={["#bd93f9"]} unit="kg" valueBands={weightBands} + targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} /> @@ -1470,6 +1471,7 @@ export default function DashboardPage() { unit="mmHg" yAxisUnit="Hg" targetZones={bpTargetZones} + targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} /> diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index d7462e195..ea5243b18 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -17,7 +17,8 @@ import { ReferenceArea, ReferenceDot, } from "recharts"; -import { Loader2 } from "lucide-react"; +import { Loader2, SlidersHorizontal } from "lucide-react"; +import Link from "next/link"; import { useState, useMemo, @@ -106,6 +107,15 @@ interface HealthChartProps { textColor?: string; lineOpacity?: number; }>; + /** + * Optional href for a small "adjust targets" link in the chart + * header. Callers whose charts paint a target band / zones (weight, + * blood pressure) pass `/settings/thresholds` so the band's origin — + * and the page that edits personal target ranges — is one click + * away. Omitted (every other mount) renders no link. Ignored in + * `mini` mode, which has no header. + */ + targetSettingsHref?: string; /** * v1.4.16 phase B5c — compact chart mode used by the Oura-style * rationale card. Drops the range tabs, the moving-average / trend @@ -514,6 +524,7 @@ export function HealthChart({ showYAxisUnit = true, valueBands, targetZones, + targetSettingsHref, mini = false, windowOverride, compareBaseline = "none", @@ -1444,6 +1455,19 @@ export function HealthChart({ )} )} + {/* Discoverability link from the painted target band to the + page that edits personal target ranges. Only mounts when + a caller passes `targetSettingsHref` (weight + BP). */} + {targetSettingsHref ? ( + +

    {displayTitle}

    + {/* `?` explainer for what the adherence percentage measures — + shares the i18n body with the per-card bars. */} + {/* v1.4.18 — 7-day trend chip is now opt-in via the "7-day trend" overlay toggle. Default OFF; the user activates it from the chart settings popover. */} diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx index 833c6560d..27c4d2a58 100644 --- a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx +++ b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx @@ -61,7 +61,7 @@ describe(" v1.4.43 M4 disabled-row behaviour", () => { /data-unavailable="true"[\s\S]*?Resting pulse/, ); expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication compliance/, + /data-unavailable="true"[\s\S]*?Medication adherence/, ); expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/); @@ -168,7 +168,7 @@ describe(" v1.4.43 M4 disabled-row behaviour", () => { ); // The compliance row exists and is marked unavailable… expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication compliance/, + /data-unavailable="true"[\s\S]*?Medication adherence/, ); // …and the disabled switch is rendered (aria-disabled or // data-disabled survives in the SSR markup). diff --git a/src/components/insights/__tests__/correlation-card.test.tsx b/src/components/insights/__tests__/correlation-card.test.tsx index 0e0937984..72c6df732 100644 --- a/src/components/insights/__tests__/correlation-card.test.tsx +++ b/src/components/insights/__tests__/correlation-card.test.tsx @@ -61,8 +61,8 @@ describe("", () => { it("renders title + subtitle + interpretation when status=ok", () => { const html = render(); expect(html).toMatch(/data-slot="correlation-card"/); - expect(html).toContain("BP × medication compliance"); - expect(html).toContain("Daily systolic against compliance %"); + expect(html).toContain("BP × medication adherence"); + expect(html).toContain("Daily systolic against adherence in %"); expect(html).toContain("a pattern worth watching"); }); @@ -99,7 +99,7 @@ describe("", () => { it("renders German copy when locale=de", () => { const html = render(, "de"); - expect(html).toContain("BD × Medikamenten-Compliance"); + expect(html).toContain("BD × Therapietreue"); expect(html).toContain("7-Tage-Experiment"); }); diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx index ccec3dbd1..7ef0d6b1f 100644 --- a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx +++ b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx @@ -149,7 +149,7 @@ describe("", () => { expect(body).not.toBeNull(); const text = body?.[1] ?? ""; // Components — window — action triad pinned. - expect(text).toContain("BP, weight, mood and meds"); + expect(text).toContain("BP, weight, mood and medication adherence"); expect(text).toContain("same time last week"); expect(text).toContain("Log a fresh reading"); // 3-sentence ceiling. Soft check: at most 4 sentence-ending @@ -171,7 +171,7 @@ describe("", () => { const text = body?.[1] ?? ""; expect(text).toContain("Blutdruck"); expect(text).toContain("Stimmung"); - expect(text).toContain("Einnahmetreue"); + expect(text).toContain("Therapietreue"); expect(text).toContain("vor einer Woche"); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 9d0a5aff3..b534791f2 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -10,6 +10,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; +import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -32,7 +33,18 @@ const PUBLIC_PATHS = [ "/c/", ]; -export function AuthShell({ children }: { children: React.ReactNode }) { +export function AuthShell({ + children, + demoMode = false, +}: { + children: React.ReactNode; + /** + * True when the instance runs with `DEMO_MODE=true` (resolved by the + * server-side root layout). Renders the persistent demo banner above + * the app chrome so a visitor knows mutations won't persist. + */ + demoMode?: boolean; +}) { const { user, isAuthenticated, isLoading } = useAuth(); const { t } = useTranslations(); const pathname = usePathname(); @@ -119,6 +131,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) { } return (
    + {demoMode ? : null}
    + {/* Demo instances surface a persistent "changes are not + saved" strip — the proxy blocks every mutation, and + without the banner that block only surfaced as a raw API + error after the user already filled in a form. */} + {demoMode ? : null}
    = user?.cycleTrackingEnabled + const cycleAwareHub: ReadonlyArray = user?.cycleTrackingEnabled ? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)] : MORE_HUB; + // Bug report sits directly before Settings (the hub's last entry) + // when the operator flag is on — same gate as the desktop sidebar. + const moreHub: ReadonlyArray = bugReportEnabled + ? [ + ...cycleAwareHub.slice(0, -1), + BUGREPORT_HUB_ITEM, + cycleAwareHub[cycleAwareHub.length - 1], + ] + : cycleAwareHub; function isActiveLink(href: string) { return href === "/" ? pathname === "/" : pathname.startsWith(href); diff --git a/src/components/layout/demo-banner.tsx b/src/components/layout/demo-banner.tsx new file mode 100644 index 000000000..af0b3f123 --- /dev/null +++ b/src/components/layout/demo-banner.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { FlaskConical } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Slim persistent banner shown while the instance runs with + * `DEMO_MODE=true`. The proxy already blocks every mutation outside a + * small allowlist (`src/proxy.ts`), but pre-banner the only feedback a + * demo visitor got was an English API error after submitting a form. + * This strip tells the visitor up front that nothing they change will + * persist. + * + * Mount strategy mirrors ``: rendered once inside + * `` so every routed page inherits it. The flag itself is a + * server-only env var, so the root layout (a server component) reads + * `process.env.DEMO_MODE` and threads the boolean through the + * `demoMode` prop on `` — no client-side detection, no + * extra request. + * + * Copy lives in `messages/*.json` under `demoBanner.message`; all six + * locales ship together. + */ +export function DemoBanner() { + const { t } = useTranslations(); + + return ( +
    +
    + ); +} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index ba4cf3670..7ee72dbc9 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -9,6 +9,7 @@ import { ChevronsRight, Droplets, Home, + Info, Lightbulb, LogOut, Monitor, @@ -156,10 +157,18 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {t("nav.notifications")} - {/* v1.4.36 W4e — About moved into the Admin Console - (`/admin/about`). Admins reach it through the Admin sidebar - and the mobile section strip; non-admin users see the - public `/about` landing instead. */} + {/* "About HealthLog" links the signed-in `/settings/about` + section (version, licences, update check). The slug also has + its own entry at the end of the settings shell nav; this + dropdown item keeps the historic discovery path alive. The + public `/about` landing stays separate for signed-out + visitors. */} + + + + {t("nav.about")} + + {themeIcon} diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 044ab318b..8619ad4ca 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -49,8 +49,8 @@ describe("medication card-parts — shared presentational components", () => { const html = render( , ); - expect(html).toContain("7-day compliance"); - expect(html).toContain("30-day compliance"); + expect(html).toContain("Adherence (7 days)"); + expect(html).toContain("Adherence (30 days)"); expect(html).toContain("90%"); expect(html).toContain("88%"); expect(html).toContain("lucide-flame"); @@ -72,10 +72,10 @@ describe("medication card-parts — shared presentational components", () => { longDays={365} />, ); - expect(html).toContain("90-day compliance"); - expect(html).toContain("365-day compliance"); - expect(html).not.toContain("7-day compliance"); - expect(html).not.toContain("30-day compliance"); + expect(html).toContain("Adherence (90 days)"); + expect(html).toContain("Adherence (365 days)"); + expect(html).not.toContain("Adherence (7 days)"); + expect(html).not.toContain("Adherence (30 days)"); }); it("compliance bars hide the streak flame when streak is zero", () => { @@ -99,8 +99,8 @@ describe("medication card-parts — shared presentational components", () => { expect(html).not.toContain("·"); expect(html).not.toContain("doses"); // The bar + the window-days label survive. - expect(html).toContain("7-day compliance"); - expect(html).toContain("30-day compliance"); + expect(html).toContain("Adherence (7 days)"); + expect(html).toContain("Adherence (30 days)"); }); it("status pill stamps the success token + take-now glyph in window", () => { diff --git a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts index a58de133c..b61b2bdf0 100644 --- a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts +++ b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { applyOptimisticSlotMark, complianceFromLedger, + formatSlotDelta, groupLedgerByDay, isSlotActionable, type LedgerPayload, @@ -109,6 +110,33 @@ describe("isSlotActionable", () => { }); }); +// v1.15.20 — the signed offset on the ad-hoc "fällig gewesen" subline. +describe("formatSlotDelta", () => { + const slot = "2026-06-01T05:00:00.000Z"; + + it("renders a take after the slot with a plus sign in minutes", () => { + expect(formatSlotDelta("2026-06-01T05:45:00.000Z", slot)).toBe("+45 min"); + }); + + it("renders a take before the slot with a minus sign", () => { + expect(formatSlotDelta("2026-06-01T04:30:00.000Z", slot)).toBe("-30 min"); + }); + + it("renders whole hours without a minutes part", () => { + expect(formatSlotDelta("2026-06-01T07:00:00.000Z", slot)).toBe("+2 h"); + }); + + it("renders mixed hours + minutes", () => { + expect(formatSlotDelta("2026-06-01T06:20:00.000Z", slot)).toBe( + "+1 h 20 min", + ); + }); + + it("collapses a sub-minute delta to +0 min", () => { + expect(formatSlotDelta("2026-06-01T05:00:20.000Z", slot)).toBe("+0 min"); + }); +}); + describe("applyOptimisticSlotMark", () => { const payload: LedgerPayload = { from: "2026-06-01T00:00:00.000Z", diff --git a/src/components/medications/card-parts/compliance-info-tip.tsx b/src/components/medications/card-parts/compliance-info-tip.tsx new file mode 100644 index 000000000..efed854c2 --- /dev/null +++ b/src/components/medications/card-parts/compliance-info-tip.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { HelpCircle } from "lucide-react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * Small `?` affordance that explains what the adherence percentage + * actually measures ("share of expected doses you logged — weekly + * schedules count week by week"). Sits next to the number on the two + * surfaces that render an adherence rate without context: the + * dashboard compliance card header and the per-medication bars + * (`medication-compliance-bars.tsx`). + * + * Pattern follows `health-score-delta-explainer.tsx` (icon-only + * trigger, 44 px hit target collapsed back to the row's stride via + * negative margins). A click-opened Popover is used on every viewport + * — unlike a hover Tooltip it stays reachable on touch devices, where + * the medication cards see most of their use. + */ +export function ComplianceInfoTip({ className }: { className?: string }) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + + return ( + + + + + +

    + {t("medications.complianceInfo")} +

    +
    +
    + ); +} diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index df38c8288..630bf52a3 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -1,5 +1,6 @@ import { Flame } from "lucide-react"; +import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip"; import { Progress } from "@/components/ui/progress"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -64,7 +65,13 @@ export function MedicationComplianceBars({
    - {shortLabel} + + {shortLabel} + {/* One `?` for the whole block — both rows measure the same + thing over different windows, so a second trigger on the + long row would only add noise. */} + + {shortPct}%
    {/* aria-label so the bar has an accessible name. */} diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts index ce0009456..514274974 100644 --- a/src/components/medications/dose-history-ledger-compute.ts +++ b/src/components/medications/dose-history-ledger-compute.ts @@ -25,6 +25,11 @@ export interface LedgerRow { /** The slot's "HH:mm" label, or null for an ad-hoc row. */ timeOfDay: string | null; status: DoseHistoryStatus; + /** v1.15.20 — slot served by a deliberate user pin ("zugeordnet"). */ + pinned?: boolean; + /** v1.15.20 — due-context for an ad-hoc take: the nearest slot it could + * belong to. `filled: false` unlocks the "Slot zuordnen" kebab action. */ + nearestSlot?: { at: string; timeOfDay: string; filled: boolean }; intake: { id: string | null; scheduledFor: string; @@ -34,6 +39,24 @@ export interface LedgerRow { } | null; } +/** + * v1.15.20 — human-compact signed offset between an ad-hoc take and the slot + * it would have been due at ("+45 min", "-2 h", "+1 h 20 min"). Unit + * abbreviations are locale-neutral (min / h); the sign reads "taken after + * (+) / before (−) the slot". Sub-minute deltas collapse to "±0 min". + */ +export function formatSlotDelta(takenAtIso: string, slotAtIso: string): string { + const deltaMs = new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime(); + const sign = deltaMs < 0 ? "-" : "+"; + const totalMinutes = Math.round(Math.abs(deltaMs) / 60_000); + if (totalMinutes < 60) return `${sign}${totalMinutes} min`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 + ? `${sign}${hours} h` + : `${sign}${hours} h ${minutes} min`; +} + /** The full `data` envelope the endpoint returns. */ export interface LedgerPayload { from: string; diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/Step8Summary.tsx index b87236fa6..29f9c2230 100644 --- a/src/components/medications/wizard/steps/Step8Summary.tsx +++ b/src/components/medications/wizard/steps/Step8Summary.tsx @@ -1,11 +1,14 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type MedicationPayload, @@ -55,6 +58,28 @@ export function Step8Summary({ const topSummary = summariseCadence(payload, t); const canRemove = payload.schedules.length > 1; + // Reminders need at least one delivery channel. Fetch the channel + // list only while the toggle is on; when the server reports zero + // enabled channels, a small hint below the toggle links to + // `/settings/notifications` so the user learns BEFORE saving that + // the reminder would have nowhere to go. Query errors / loading + // render no hint — a false "no channel" warning is worse than none. + const { data: notificationChannels } = useQuery({ + queryKey: queryKeys.notificationsStatus(), + queryFn: async () => { + const res = await fetch("/api/notifications/status"); + if (!res.ok) throw new Error("Failed"); + return ((await res.json()).data as { channels: Array<{ enabled: boolean }> }) + .channels; + }, + enabled: payload.notificationsEnabled, + staleTime: 60_000, + }); + const showNoChannelHint = + payload.notificationsEnabled && + Array.isArray(notificationChannels) && + !notificationChannels.some((channel) => channel.enabled); + return (
    + {showNoChannelHint && ( +

    + {t("medications.wizard.steps.step8.noChannelHint")}{" "} + + {t("medications.wizard.steps.step8.noChannelHintLink")} + +

    + )} + {submitError && (

    { describe("", () => { it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => { const html = renderShell({ active: "account" }); - // v1.4.33 IW7 — the `about` slug is alive as a route - // (`/settings/about` still resolves and `generateStaticParams()` - // still emits the page) but it is hidden from the in-shell nav. - // The user-card dropdown owns the link now. Iterate the visible - // sections list, not the slug list, when asserting on rendered - // markup. + // Every slug — including `about`, which returned to the shell nav + // as the last entry after living dropdown-only since v1.4.33 IW7 — + // renders in both layouts. const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug); for (const slug of navigableSlugs) { const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); @@ -100,8 +97,6 @@ describe("", () => { // before media queries resolve. expect(matches?.length ?? 0).toBe(2); } - // The hidden `about` slug must NOT appear in the in-shell nav. - expect(html).not.toContain('href="/settings/about"'); }); it("links use the correct `/settings/` href — regression guard against typos", () => { @@ -161,10 +156,9 @@ describe("", () => { // entry in the sidebar; the link must be present in both locales. expect(html).toContain('href="/settings/export"'); expect(html).toContain("Advanced"); - // v1.4.33 IW7 — About is no longer in the settings nav; it lives - // in the sidebar user-card dropdown. Route `/settings/about` is - // still alive for direct links. - expect(html).not.toContain('href="/settings/about"'); + // About is back in the settings nav as the last entry (it also + // stays linked from the sidebar user-card dropdown). + expect(html).toContain('href="/settings/about"'); // v1.8.7.1 — Targets and Sources are two separate nav entries again. expect(html).toContain('href="/settings/thresholds"'); expect(html).toContain('href="/settings/sources"'); @@ -197,9 +191,9 @@ describe("", () => { // API & Tokens is identical in both locales (proper noun + ampersand) expect(html).toContain("API & Tokens"); expect(html).toContain("Erweitert"); - // v1.4.33 IW7 — "Über" (About) section removed from the in-shell - // nav, folded into the sidebar user-card dropdown ("Über HealthLog"). - expect(html).not.toContain('href="/settings/about"'); + // "Über diese App" (About) is back as the last in-shell nav entry + // (the sidebar user-card dropdown keeps its own link too). + expect(html).toContain('href="/settings/about"'); // v1.8.7.1 — both Targets and Sources nav entries are present. expect(html).toContain('href="/settings/sources"'); }); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 91c9dcf03..4d3999ee6 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -1,7 +1,10 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { Activity, AlertCircle, @@ -170,12 +173,78 @@ function IntegrationErrorMessage({ message }: { message: string }) { ); } +/** + * The Withings OAuth callback (`/api/withings/callback`) redirects back + * here with `?withings=connected` or `?withings=error&reason=`. + * Map every reason tag the callback emits onto a human-readable i18n + * key (what went wrong + what to do next). Unknown tags fall back to + * the generic entry so a future callback branch never strands the user + * with silent params. + */ +const WITHINGS_OAUTH_ERROR_KEYS: Record = { + csrf1: "settings.withingsOauthError.csrf1", + replay: "settings.withingsOauthError.replay", + state: "settings.withingsOauthError.state", + expired: "settings.withingsOauthError.expired", + cross_user: "settings.withingsOauthError.cross_user", + nocode: "settings.withingsOauthError.nocode", + nocreds: "settings.withingsOauthError.nocreds", + token: "settings.withingsOauthError.token", +}; + +type WithingsOauthOutcome = + | { kind: "connected" } + | { kind: "error"; reason: string }; + export function IntegrationsSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const router = useRouter(); + const queryClient = useQueryClient(); const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + // OAuth callback handler — reads `?withings=connected|error&reason=…` + // from the URL (lazy initialiser, same shape as the Codex handler in + // `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the + // callback set these params and nothing ever read them: a user came + // back from Withings onto a silently unchanged settings page. + const [withingsOauthOutcome] = useState(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const status = params.get("withings"); + if (status === "connected") return { kind: "connected" }; + if (status === "error") { + return { kind: "error", reason: params.get("reason") ?? "unknown" }; + } + return null; + }); + + useEffect(() => { + if (!withingsOauthOutcome) return; + // Scrub the one-shot params so a reload / bookmark doesn't replay + // the toast. + const url = new URL(window.location.href); + url.searchParams.delete("withings"); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + if (withingsOauthOutcome.kind === "connected") { + toast.success(t("settings.withingsOauthConnected")); + queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + const reasonKey = + WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? + "settings.withingsOauthError.generic"; + toast.error(t("settings.withingsOauthFailed"), { + description: t(reasonKey), + duration: 10_000, + }); + } + }, [withingsOauthOutcome, router, queryClient, t]); + const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); @@ -195,6 +264,20 @@ export function IntegrationsSection() {

    {t("settings.sections.integrations.description")}

    + {/* Cross-link to Settings → Sources: when two integrations (or + an integration + manual entry) report the same metric, the + source-priority ladder decides which value counts — a fact + newcomers otherwise discover only after a confusing chart. */} +

    + {t("settings.integrationsSourcesHint")}{" "} + + {t("settings.integrationsSourcesHintLink")} + +

    diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 74b16e1ed..c03e123b0 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -7,6 +7,7 @@ import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; import { NotificationStatusCard } from "@/components/settings/notification-status-card"; import { NtfyCard } from "@/components/settings/ntfy-card"; @@ -110,6 +111,10 @@ export function NotificationsSection() {
    + {/* v1.15.20 — proactive Coach nudge opt-out. */} +
    + +
  • ); } diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 22a2c0ebf..249e04aa6 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -20,6 +20,7 @@ import { usePathname } from "next/navigation"; import { Bell, Download, + Info, KeyRound, Layers, LayoutDashboard, @@ -66,15 +67,11 @@ interface SettingsSection { * in its sidebar + mobile chip-strip. Order matches the in-app * navigation order. Don't reorder without updating tests. * - * v1.4.33 IW7 — "About" was folded into the sidebar user-card - * dropdown (sidebar-nav.tsx -> "Über HealthLog" / "About HealthLog") - * because its three small cards (identity / links / update check) - * collectively read once or twice a year and didn't earn a top-level - * settings slot. The route `/settings/about` is still alive — the - * slug stays in `SETTINGS_SECTION_SLUGS` so `generateStaticParams()` - * keeps emitting the page — but it no longer appears in the in-page - * navigation. Direct links (the user-card dropdown, the public - * `/about` permalink) continue to resolve to the same component. + * "About" sits at the end of the list. v1.4.33 IW7 had folded it into + * the sidebar user-card dropdown only, which left `/settings/about` an + * orphaned route — reachable by URL but discoverable nowhere in the + * settings navigation. It is now both a regular (last) shell entry and + * a user-card dropdown item ("Über HealthLog" / "About HealthLog"). * * v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two * separate sidebar entries again (merged into a single "Targets & @@ -125,6 +122,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.advanced.title", icon: Settings2, }, + { + slug: "about", + titleKey: "settings.sections.about.title", + icon: Info, + }, ] as const; export interface SettingsShellProps { diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 35ece6b40..e3ba990ef 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -28,6 +28,7 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -307,6 +308,18 @@ export function SourcesSection() {

    {t("settings.sections.sources.description")}

    + {/* Cross-link back to Settings → Integrations — the page that + actually adds / removes the sources this ladder ranks. */} +

    + {t("settings.sections.sources.integrationsHint")}{" "} + + {t("settings.sections.sources.integrationsHintLink")} + +

    diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index bdda594ec..fb460441b 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -362,7 +362,7 @@ describe("i18n locale file integrity", () => { ["insights.healthScore.componentBp", "Blutdruck"], ["insights.healthScore.componentWeight", "Gewicht"], ["insights.healthScore.componentMood", "Stimmung"], - ["insights.healthScore.componentCompliance", "Einnahmetreue"], + ["insights.healthScore.componentCompliance", "Therapietreue"], ]; for (const [key, expectedDe] of EXPECTED) { From 51dd6141991275d4758959819c8cfd21de0696c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:40:48 +0200 Subject: [PATCH 11/21] feat(coach,auth): self-context for the assistant, invite links, proactive nudges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now tell the assistant about themselves in an encrypted free-text profile that feeds the coach and briefing prompts as clearly fenced context, with the coach allowed one targeted follow-up question when the picture is incomplete. Operators on closed registrations can mint expiring invite links with a QR code from the admin area; a valid invite opens registration exactly as many times as it allows and records who redeemed it. A nightly pass watches three deterministic signals — low adherence, blood pressure above its target band, a falling recovery trend — and sends at most one coach nudge per week through the existing notification channels, gated behind the same opt-outs as every other assistant surface. --- messages/de.json | 195 ++++++--- messages/en.json | 165 ++++++-- messages/es.json | 113 +++++- messages/fr.json | 113 +++++- messages/it.json | 113 +++++- messages/pl.json | 113 +++++- package.json | 2 + pnpm-lock.yaml | 152 +++++++ .../migration.sql | 16 + .../migration.sql | 22 ++ .../0141_v11520_invite_tokens/migration.sql | 32 ++ prisma/schema.prisma | 81 ++++ src/app/admin/[section]/renderer.tsx | 8 +- src/app/api/admin/invites/[id]/route.ts | 41 ++ src/app/api/admin/invites/route.ts | 110 ++++++ .../__tests__/route.test.ts | 11 + src/app/api/auth/register/route.ts | 49 ++- src/app/api/coach/about-me/route.ts | 144 +++++++ src/app/api/insights/chat/route.ts | 8 +- src/app/api/insights/generate/route.ts | 11 + src/app/auth/register/page.tsx | 28 +- .../admin/invite-tokens-section.tsx | 309 +++++++++++++++ src/components/settings/about-me-section.tsx | 170 ++++++++ src/components/settings/ai-section.tsx | 6 + src/components/settings/coach-nudge-card.tsx | 151 +++++++ src/lib/ai/coach/about-me.ts | 80 ++++ src/lib/ai/coach/system-prompt.ts | 47 ++- src/lib/auth/invite-token.ts | 138 +++++++ .../__tests__/coach-cascade.test.tsx | 5 + src/lib/insights/comprehensive-generate.ts | 11 + src/lib/jobs/__tests__/coach-nudge.test.ts | 146 +++++++ src/lib/jobs/coach-nudge.ts | 373 ++++++++++++++++++ src/lib/jobs/reminder-worker.ts | 47 +++ src/lib/notifications/senders/web-push.ts | 11 +- src/lib/notifications/types.ts | 12 + src/lib/openapi/routes.ts | 157 ++++++++ src/lib/query-keys.ts | 4 + 37 files changed, 3072 insertions(+), 122 deletions(-) create mode 100644 prisma/migrations/0139_v11520_intake_attribution_source/migration.sql create mode 100644 prisma/migrations/0140_v11520_user_health_profile/migration.sql create mode 100644 prisma/migrations/0141_v11520_invite_tokens/migration.sql create mode 100644 src/app/api/admin/invites/[id]/route.ts create mode 100644 src/app/api/admin/invites/route.ts create mode 100644 src/app/api/coach/about-me/route.ts create mode 100644 src/components/admin/invite-tokens-section.tsx create mode 100644 src/components/settings/about-me-section.tsx create mode 100644 src/components/settings/coach-nudge-card.tsx create mode 100644 src/lib/ai/coach/about-me.ts create mode 100644 src/lib/auth/invite-token.ts create mode 100644 src/lib/jobs/__tests__/coach-nudge.test.ts create mode 100644 src/lib/jobs/coach-nudge.ts diff --git a/messages/de.json b/messages/de.json index 1bd0337a4..378ac8b0e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,11 +58,11 @@ "classification": "Klassifikation", "bmiTitle": "Body-Mass-Index (BMI)", "bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)", - "complianceTitle": "Medikamenten-Compliance", + "complianceTitle": "Therapietreue", "glp1Title": "GLP-1-Therapie", "glp1WeightSummary": "Gewicht im Berichtszeitraum: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Aktuelle Dosis: {value} {unit} (seit {since})", - "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)", + "glp1Compliance": "Therapietreue: {taken} / {total} ({rate}%)", "glp1SideEffectsTitle": "Erfasste Nebenwirkungen", "colGlp1Date": "Datum", "colGlp1Dose": "Dosis", @@ -73,7 +73,7 @@ "colSkipped": "Übersprungen", "colMissed": "Verpasst", "colTotal": "Gesamt", - "colComplianceRate": "Compliance-Rate", + "colComplianceRate": "Therapietreue", "moodTitle": "Stimmungsverlauf", "moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}", "colMood": "Stimmung", @@ -144,7 +144,7 @@ "bmi": "Body-Mass-Index (BMI)", "mood": "Stimmung", "moodSensitive": "Sensibel — standardmäßig aus.", - "compliance": "Medikamenten-Compliance", + "compliance": "Therapietreue", "sleep": "Schlafdauer", "cycle": "Menstruationszyklus", "empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.", @@ -155,7 +155,7 @@ "summaryTitle": "Klinische Zusammenfassung", "summaryReadings": "Über {days} Tage erfasst: {count} Messwerte über {params} Parameter.", "summaryTrend": "{label}: zuletzt {latest} {arrow}", - "summaryAdherence": "Medikamenten-Einnahmetreue: {rate}% (gewichteter Mittelwert).", + "summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).", "chartsTitle": "Trend-Diagramme", "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert", "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist." }, + "demoBanner": { + "message": "Demo-Modus — Änderungen werden nicht gespeichert." + }, "nav": { "dashboard": "Dashboard", "measurements": "Messungen", @@ -251,6 +254,7 @@ "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen", + "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "createAccount": "Konto erstellen", "newAccount": "Neues Konto erstellen", "backToPasskey": "Zurück zu Passkey", @@ -314,7 +318,7 @@ "confirm": "Verwerfen", "cancel": "Weiter bearbeiten" }, - "compliance7d": "Einnahmetreue (7 Tage)", + "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", "layoutReset": "Auf Defaults zurücksetzen", @@ -646,7 +650,7 @@ "slotError": "Der gewählte Slot konnte keiner Uhrzeit zugeordnet werden. Bitte wähle einen anderen Slot oder „Kein bestimmter Slot“." }, "emptyTitle": "Noch keine Medikamente", - "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.", + "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um die Therapietreue zu erfassen.", "loadFailed": "Medikamente konnten nicht geladen werden", "retryLoad": "Erneut laden", "name": "Name", @@ -660,7 +664,7 @@ "intakeHistory": "Einnahme-Verlauf", "openDetailPage": "Medikament-Detailseite öffnen", "deleteConfirm": "Medikament löschen?", - "compliance": "Einnahmetreue", + "compliance": "Therapietreue", "importIntakes": "Einnahmen importieren", "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", "importUploadFile": "JSON-Datei hochladen", @@ -716,9 +720,11 @@ "takeNow": "Jetzt einnehmen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", - "compliance7d": "7-Tage-Compliance", - "compliance30d": "30-Tage-Compliance", - "complianceWindow": "{days}-Tage-Compliance", + "compliance7d": "Therapietreue (7 Tage)", + "compliance30d": "Therapietreue (30 Tage)", + "complianceWindow": "Therapietreue ({days} Tage)", + "complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.", + "complianceInfoLabel": "Was bedeutet Therapietreue?", "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", @@ -1046,6 +1052,8 @@ "subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.", "remindersLabel": "Erinnerungen aktiv", "remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.", + "noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal — aktuell ist keiner aktiv.", + "noChannelHintLink": "Benachrichtigungen einrichten", "multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.", "short": "Fertig" } @@ -1363,6 +1371,18 @@ "body": "Die Einnahme liegt knapp außerhalb des Fensters von {slot}. Diesem Slot zuordnen oder als außerplanmäßigen Eintrag speichern?", "keepAdHoc": "Außerplanmäßig", "pin": "Zuordnen" + }, + "pin": { + "action": "Slot {time} zuordnen", + "release": "Zuordnung lösen", + "pinnedTag": "zugeordnet", + "pinnedToast": "Einnahme dem Slot zugeordnet", + "unpinnedToast": "Zuordnung gelöst", + "dueContext": "fällig gewesen: {time} ({delta})", + "confirmTitle": "Slot {time} zuordnen?", + "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet – die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmCancel": "Abbrechen", + "confirmAction": "Zuordnen" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Diagramm wird geladen…", "loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.", + "adjustTargets": "Zielwerte anpassen", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", @@ -1695,7 +1716,7 @@ "lastMonth": "Vergleich nicht verfügbar — noch keine Daten aus dem Vormonat", "lastYear": "Vergleich nicht verfügbar — noch keine Daten aus dem Vorjahr" }, - "notAvailableForCompliance": "Vergleich für Compliance nicht verfügbar" + "notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar" }, "insights": { "emptyTitle": "Noch nicht genug Daten", @@ -1803,13 +1824,13 @@ "componentBp": "Blutdruck", "componentWeight": "Gewicht", "componentMood": "Stimmung", - "componentCompliance": "Einnahmetreue", + "componentCompliance": "Therapietreue", "disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.", "deltaExplainer": { "trigger": "Was bedeutet diese Veränderung?", "title": "So wird die Veränderung berechnet", "description": "Veränderung gegenüber letzter Woche: {delta}", - "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Einnahmetreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen." + "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Therapietreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen." }, "provenance": { "toggle": "Zusammensetzung", @@ -1911,8 +1932,8 @@ "confidenceModerate": "Mittlere Konfidenz", "confidenceLow": "Geringe Konfidenz", "card": { - "bpComplianceTitle": "BD × Medikamenten-Compliance", - "bpComplianceSubtitle": "Täglicher Systolischer gegen Compliance-%", + "bpComplianceTitle": "BD × Therapietreue", + "bpComplianceSubtitle": "Täglicher Systolischer gegen Therapietreue in %", "moodPulseTitle": "Stimmung × Ruhepuls", "moodPulseSubtitle": "Stimmung und Ruhepuls am selben Tag", "weightWeekdayTitle": "Gewicht × Wochentag", @@ -1953,7 +1974,7 @@ "weight": "Gewicht", "pulse": "Puls", "mood": "Stimmung", - "compliance": "Einnahmetreue", + "compliance": "Therapietreue", "general": "Allgemein", "hrv": "HRV", "sleep": "Schlaf", @@ -2220,13 +2241,13 @@ "weightTitle": "Gewicht", "weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.", "bmiTitle": "Body-Mass-Index (BMI)", - "bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", + "bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", "sleepTitle": "Schlaf", "sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.", "moodTitle": "Stimmung", - "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", + "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", "medicationsTitle": "Medikamenten-Therapietreue", - "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", + "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", "restingHrTitle": "Ruhepuls", "restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.", "hrvTitle": "Herzfrequenzvariabilität (HRV)", @@ -2234,11 +2255,11 @@ "oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)", "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.", "bodyTemperatureTitle": "Körpertemperatur", - "bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.", + "bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.", "activeEnergyTitle": "Aktive Energie", - "activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.", + "activeEnergyBody": "Die aktive Energie sind die Kalorien, die du durch Bewegung verbrennst, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv dein Tag war.", "workoutsTitle": "Workouts", - "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt.", + "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die deine Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit deinen anderen Gesundheitssignalen zusammenhängt.", "respiratoryRateTitle": "Atemfrequenz", "respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.", "bodyWaterTitle": "Gesamtkörperwasser", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.", - "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert bedeutet, dass dein System erholt und belastbar wirkt; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten und deinem Körper Zeit zum Aufholen zu geben. Sieh ihn als Orientierung für die Tagesplanung, nicht als Urteil.", - "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen und wägt ab, wie lange und wie effizient du geschlafen hast, wie regelmäßig deine Zeiten waren und wie sich Leicht-, Tief- und REM-Schlaf verteilen. Er soll auf einen Blick zeigen, ob die Nacht dich wirklich erholt hat. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", - "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat und wie viel Kapazität du heute für Belastung hast — sie stützt sich auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut. Er ist eine Orientierung, wie stark du dich einbringen kannst, keine starre Grenze.", - "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen und spiegelt so, was speziell für dich fordernd ist. Nutze sie, um anstrengende Tage mit ruhigeren auszugleichen und dein Training tragfähig zu halten.", - "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigeren, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen. Er ist ein beschreibender Näherungswert, um Trends zu erkennen, keine klinische Messung." + "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", + "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", + "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", + "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen, nicht an einem fremden Maßstab.", + "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen." }, "stats": { "label": "Zusammenfassende Statistik", @@ -2361,10 +2382,10 @@ } }, "correlations": "Korrelationen", - "medicationCompliance": "Medikamenten-Compliance", + "medicationCompliance": "Therapietreue", "dayStreak": "{count} Tage Serie", - "compliance7d": "7-Tage-Compliance", - "compliance30d": "30-Tage-Compliance", + "compliance7d": "Therapietreue (7 Tage)", + "compliance30d": "Therapietreue (30 Tage)", "taken": "genommen", "skipped": "übersprungen", "missed": "verpasst", @@ -2380,7 +2401,7 @@ "weightSectionTitle": "Gewicht", "pulseSectionTitle": "Puls", "bmiSectionTitle": "BMI", - "complianceNoData": "Keine Compliance-Daten verfügbar", + "complianceNoData": "Keine Daten zur Therapietreue verfügbar", "generate": "Neue Analyse erstellen", "moodSectionTitle": "Stimmung", "confidence": "Konfidenz", @@ -2413,7 +2434,7 @@ }, "medication": { "title": "Noch keine Medikamente angelegt", - "description": "Lege ein Medikament an, dann erscheinen Einnahme-Compliance, Wirkstoffspiegel und Eskalations-Verlauf.", + "description": "Lege ein Medikament an, dann erscheinen Therapietreue, Wirkstoffspiegel und Eskalations-Verlauf.", "cta": "Medikament hinzufügen" }, "sleep": { @@ -3331,7 +3352,7 @@ "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.", "measurementCta": "Messung hinzufügen", "medicationTitle": "Medikament hinzufügen", - "medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.", + "medicationDescription": "Verfolge die Therapietreue und erhalte Erinnerungen genau dann, wenn du sie brauchst.", "medicationCta": "Medikament hinzufügen", "withingsTitle": "Withings verbinden (optional)", "withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.", @@ -3386,6 +3407,8 @@ "sources": { "title": "Quellen-Priorität", "description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.", + "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", + "integrationsHintLink": "Integrationen öffnen", "cardTitle": "Quellen-Priorität pro Metrik", "help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.", "resetDefaults": "Auf Standard zurücksetzen", @@ -3459,7 +3482,7 @@ "downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.", "hero": { "eyebrow": "Arzttermin", - "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Medikamenten-Compliance und (optional) Stimmungsverlauf, alles auf wenigen Seiten.", + "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Therapietreue und (optional) Stimmungsverlauf, alles auf wenigen Seiten.", "cta": "Bericht konfigurieren & generieren", "formatHint": "PDF · druckfertig" }, @@ -3473,7 +3496,7 @@ "cards": { "doctorReport": { "title": "Arztbericht", - "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Medikamenten-Compliance, Stimmung." + "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Therapietreue, Stimmung." }, "measurementsCsv": { "title": "Messwerte", @@ -3655,8 +3678,8 @@ "codexDisconnected": "ChatGPT-Verbindung getrennt.", "codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.", "rawData": "Rohdaten mitsenden", - "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Provider zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.", - "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Provider kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.", + "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte einzelne Messpunkte der letzten 30 Tage mit Zeitbezug gesendet — ohne Name, E-Mail oder direkte Kontodaten.", + "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum) — ohne einzelne Messpunkte und exakte Zeitbezüge.", "rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.", "regenerateInsights": "Reports neu generieren", "regenerateSuccess": "Reports wurden neu generiert", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach ist ausgeblendet.", "savedShown": "Coach ist sichtbar.", "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + }, + "aboutMe": { + "title": "Über mich", + "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", + "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon …", + "charCount": "{used} / {max} Zeichen", + "clear": "Leeren", + "save": "Speichern", + "savedToast": "Über-mich-Notiz gespeichert.", + "clearedToast": "Über-mich-Notiz gelöscht.", + "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.", + "loadError": "Die Notiz konnte nicht geladen werden.", + "hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} Messwerte synchronisiert", "withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert", "withingsSyncFailed": "Sync fehlgeschlagen", + "withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.", + "withingsOauthFailed": "Withings-Verbindung fehlgeschlagen", + "withingsOauthError": { + "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Starte die Verbindung bitte erneut im selben Browser.", + "replay": "Dieser Bestätigungslink wurde bereits verwendet. Starte die Verbindung bitte erneut.", + "state": "Die Antwort von Withings passte zu keiner offenen Verbindungsanfrage. Starte die Verbindung bitte erneut.", + "expired": "Die Verbindungsanfrage ist abgelaufen. Starte die Verbindung erneut und schließe die Freigabe innerhalb weniger Minuten ab.", + "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Starte die Verbindung bitte aus deinem eigenen Konto neu.", + "nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.", + "nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.", + "token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.", + "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut." + }, + "integrationsSourcesHint": "Liefern mehrere Quellen dieselbe Metrik, entscheidet die Quellen-Priorität, welcher Wert zählt.", + "integrationsSourcesHintLink": "Quellen-Priorität öffnen", "withingsSynchronize": "Synchronisieren", "withingsDisconnect": "Trennen", "withingsDisconnectTitle": "Withings-Verbindung trennen?", @@ -4062,7 +4113,7 @@ "distance": "Distanz", "sleep": "Schlaf", "glucose": "Blutzucker", - "medications": "Medikamente & Einnahmetreue", + "medications": "Medikamente & Therapietreue", "mood": "Stimmung (standardmäßig aus — sensible Daten)", "bmi": "BMI", "includeCharts": "Trend-Diagramme einbetten (nur PDF)", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue-Token", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Einladungen", + "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.", + "createLabel": "Neue Einladung:", + "createDays": "{days} Tage", + "createdToast": "Einladung erstellt.", + "createError": "Einladung konnte nicht erstellt werden.", + "tokenTitle": "Einladungslink", + "tokenOnceHint": "Wird nur jetzt angezeigt — kopiere den Link oder lass den QR-Code scannen.", + "copyLink": "Link kopieren", + "copiedToast": "Link kopiert.", + "copyError": "Kopieren fehlgeschlagen.", + "qrAlt": "QR-Code des Einladungslinks", + "loadError": "Einladungen konnten nicht geladen werden.", + "listEmpty": "Keine Einladungen vorhanden.", + "expires": "läuft ab am {date}", + "uses": "{used} / {max} genutzt", + "createdMeta": "Erstellt am {date} von {by}", + "usedBy": "zuletzt genutzt von {username}", + "statusActive": "Aktiv", + "statusExpired": "Abgelaufen", + "statusExhausted": "Aufgebraucht", + "deleteAria": "Einladung löschen", + "deleteConfirmTitle": "Einladung löschen?", + "deleteConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen.", + "deleteConfirmAction": "Löschen", + "cancel": "Abbrechen", + "deletedToast": "Einladung gelöscht.", + "deleteError": "Einladung konnte nicht gelöscht werden." + }, "userManagement": "Benutzerverwaltung", "userName": "Name", "userEmail": "E-Mail", @@ -4649,7 +4730,7 @@ }, "compliance801": { "title": "Treue 80% · 1T", - "description": "Erreiche an einem Tag eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche an einem Tag eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen1": { "title": "BMI im Grün · 1T", @@ -4669,7 +4750,7 @@ }, "compliance807": { "title": "Treue 80% · 7T", - "description": "Erreiche 7 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 7 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen7": { "title": "BMI im Grün · 7T", @@ -4689,7 +4770,7 @@ }, "compliance8030": { "title": "Treue 80% · 30T", - "description": "Erreiche 30 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 30 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen30": { "title": "BMI im Grün · 30T", @@ -4709,7 +4790,7 @@ }, "compliance80180": { "title": "Treue 80% · 180T", - "description": "Erreiche 180 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 180 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen180": { "title": "BMI im Grün · 180T", @@ -4729,7 +4810,7 @@ }, "compliance80360": { "title": "Treue 80% · 360T", - "description": "Erreiche 360 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 360 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen360": { "title": "BMI im Grün · 360T", @@ -4916,12 +4997,22 @@ "hourAria": "Stunde der Stimmungs-Erinnerung", "hourSavedToast": "Erinnerungszeit aktualisiert." }, + "coachNudge": { + "title": "Proaktiver Coach", + "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.", + "toggleAria": "Proaktive Coach-Hinweise umschalten", + "statusOn": "Aktiv — max. 1× pro Woche", + "statusOff": "Aus", + "enabledToast": "Proaktive Coach-Hinweise aktiviert.", + "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." + }, "eventMedicationReminder": "Medikamenten-Erinnerung", "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", "eventMeasurementAnomaly": "Auffälliger Messwert", "eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.", - "eventComplianceLow": "Niedrige Einnahmequote", - "eventComplianceLowDesc": "Wird gesendet, wenn deine Einnahmequote in den letzten Tagen unter 80 % fällt.", + "eventComplianceLow": "Niedrige Therapietreue", + "eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.", "eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen", "eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.", "eventSystemAlert": "Systemmeldung", @@ -5102,6 +5193,14 @@ "dailyTitle": "Stimmung erfassen", "dailyBody": "Wie geht es dir heute?" }, + "coachNudges": { + "complianceTitle": "Dein Coach hätte einen Gedanken dazu", + "complianceBody": "Deine Einnahmen waren diese Woche lückenhaft. Sprich kurz mit dem Coach — vielleicht lässt sich der Plan vereinfachen.", + "bpTitle": "Blutdruck im Wochenmittel erhöht", + "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.", + "scoreTitle": "Dein Erholungswert fällt", + "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen." + }, "clinicianView": { "title": "Geteilte Gesundheitsakte", "period": "Berichtszeitraum: {start} – {end}", @@ -5109,10 +5208,10 @@ "provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.", "vitals": "Vitalwerte", "labs": "Laborwerte", - "medications": "Medikamente & Einnahmetreue", + "medications": "Medikamente & Therapietreue", "bmi": "Body-Mass-Index (BMI)", "statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})", - "adherence": "{rate} Einnahmetreue", + "adherence": "{rate} Therapietreue", "noAdherence": "—", "glucose": { "FASTING": "Glukose (nüchtern)", diff --git a/messages/en.json b/messages/en.json index 12ea7fd8a..f39b413fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -58,11 +58,11 @@ "classification": "Classification", "bmiTitle": "Body Mass Index (BMI)", "bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Medication adherence", "glp1Title": "GLP-1 therapy", "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Current dose: {value} {unit} (since {since})", - "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)", + "glp1Compliance": "Adherence: {taken} / {total} ({rate}%)", "glp1SideEffectsTitle": "Side effects logged", "colGlp1Date": "Date", "colGlp1Dose": "Dose", @@ -73,7 +73,7 @@ "colSkipped": "Skipped", "colMissed": "Missed", "colTotal": "Total", - "colComplianceRate": "Compliance rate", + "colComplianceRate": "Adherence rate", "moodTitle": "Mood trajectory", "moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Mood", @@ -144,7 +144,7 @@ "bmi": "Body Mass Index (BMI)", "mood": "Mood", "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", + "compliance": "Medication adherence", "sleep": "Sleep duration", "cycle": "Menstrual cycle", "empty": "No data was recorded in this date range yet. The report will still include the cover page.", @@ -192,6 +192,9 @@ "offlineBanner": { "message": "No connection — your changes will save once you're back online." }, + "demoBanner": { + "message": "Demo mode — changes are not saved." + }, "nav": { "dashboard": "Dashboard", "measurements": "Measurements", @@ -251,6 +254,7 @@ "hasAccount": "Already have an account?", "loginFailed": "Login failed", "registerFailed": "Registration failed", + "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.", "createAccount": "Create account", "newAccount": "Create new account", "backToPasskey": "Back to Passkey", @@ -314,7 +318,7 @@ "confirm": "Discard", "cancel": "Keep editing" }, - "compliance7d": "Compliance (7 days)", + "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", "layoutReset": "Reset to defaults", @@ -646,7 +650,7 @@ "slotError": "The selected slot couldn’t be matched to a time. Pick another slot or “No specific slot”." }, "emptyTitle": "No medications yet", - "emptyDescription": "Add medications, dosages, and reminder windows to track compliance.", + "emptyDescription": "Add medications, dosages, and reminder windows to track adherence.", "loadFailed": "Could not load medications", "retryLoad": "Retry", "name": "Name", @@ -660,7 +664,7 @@ "intakeHistory": "Intake history", "openDetailPage": "Open medication detail page", "deleteConfirm": "Delete medication?", - "compliance": "Compliance", + "compliance": "Adherence", "importIntakes": "Import intakes", "importDescription": "Paste a JSON array with intake data. Expected format:", "importUploadFile": "Upload JSON file", @@ -716,9 +720,11 @@ "takeNow": "Take now", "overdue": "Overdue", "veryOverdue": "Very overdue", - "compliance7d": "7-day compliance", - "compliance30d": "30-day compliance", - "complianceWindow": "{days}-day compliance", + "compliance7d": "Adherence (7 days)", + "compliance30d": "Adherence (30 days)", + "complianceWindow": "Adherence ({days} days)", + "complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.", + "complianceInfoLabel": "What does adherence mean?", "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", @@ -1046,6 +1052,8 @@ "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", + "noChannelHint": "Reminders need a notification channel — none is active right now.", + "noChannelHintLink": "Set up notifications", "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", "short": "Done" } @@ -1363,6 +1371,18 @@ "body": "This intake falls just outside the {slot} window. Attribute it to that slot, or keep it as an off-schedule entry?", "keepAdHoc": "Off-schedule", "pin": "Attribute" + }, + "pin": { + "action": "Attribute to slot {time}", + "release": "Release attribution", + "pinnedTag": "attributed", + "pinnedToast": "Intake attributed to slot", + "unpinnedToast": "Attribution released", + "dueContext": "was due: {time} ({delta})", + "confirmTitle": "Attribute to slot {time}?", + "confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.", + "confirmCancel": "Cancel", + "confirmAction": "Attribute" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Loading chart…", "loadingSlowHint": "Computing analytics — this can take a moment.", + "adjustTargets": "Adjust targets", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", @@ -1695,7 +1716,7 @@ "lastMonth": "Comparison unavailable — no data from last month yet", "lastYear": "Comparison unavailable — no data from last year yet" }, - "notAvailableForCompliance": "Comparison N/A for compliance" + "notAvailableForCompliance": "Comparison N/A for adherence" }, "insights": { "noAnalysisYet": "No assessment yet.", @@ -1803,13 +1824,13 @@ "componentBp": "BP", "componentWeight": "Weight", "componentMood": "Mood", - "componentCompliance": "Meds", + "componentCompliance": "Adherence", "disclaimer": "Indicative — not a clinical assessment.", "deltaExplainer": { "trigger": "What does this delta mean?", "title": "How the delta is computed", "description": "Change vs last week: {delta}", - "body": "The score combines BP, weight, mood and meds. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up." + "body": "The score combines BP, weight, mood and medication adherence. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up." }, "provenance": { "toggle": "Driven by", @@ -1911,8 +1932,8 @@ "confidenceModerate": "Moderate confidence", "confidenceLow": "Low confidence", "card": { - "bpComplianceTitle": "BP × medication compliance", - "bpComplianceSubtitle": "Daily systolic against compliance %", + "bpComplianceTitle": "BP × medication adherence", + "bpComplianceSubtitle": "Daily systolic against adherence in %", "moodPulseTitle": "Mood × resting pulse", "moodPulseSubtitle": "Same-day mood and resting pulse", "weightWeekdayTitle": "Weight × weekday", @@ -1953,7 +1974,7 @@ "weight": "Weight", "pulse": "Pulse", "mood": "Mood", - "compliance": "Compliance", + "compliance": "Adherence", "general": "General", "hrv": "HRV", "sleep": "Sleep", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", "breathingDisturbancesTitle": "Sleep breathing disturbances", "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.", - "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means your system looks recovered and resourced; a lower one is a gentle cue to ease off and let your body catch up. Treat it as guidance for pacing the day, not a verdict.", - "sleepScoreBody": "Your sleep score distils a whole night into one number, weighing how long you slept, how efficiently, how regular your timing was, and the balance of light, deep, and REM. It is meant to show at a glance whether the night truly restored you. Watch the pattern across weeks rather than chasing a perfect score on any single night.", - "recoveryBody": "Recovery reflects how well your body has bounced back and how much capacity you have to take on load today, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding. It is a guide for how hard to lean in, not a limit.", - "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, so it reflects what is demanding for you specifically. Use it to balance hard days with easier ones and keep your training sustainable.", - "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calmer, well-regulated state; a higher one is an invitation to build in recovery. It is a descriptive proxy to notice trends, not a clinical measurement." + "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.", + "sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.", + "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.", + "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, not someone else's baseline.", + "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery." }, "stats": { "label": "Summary statistics", @@ -2361,10 +2382,10 @@ } }, "correlations": "Correlations", - "medicationCompliance": "Medication Compliance", + "medicationCompliance": "Medication adherence", "dayStreak": "{count} day streak", - "compliance7d": "7-day compliance", - "compliance30d": "30-day compliance", + "compliance7d": "Adherence (7 days)", + "compliance30d": "Adherence (30 days)", "taken": "taken", "skipped": "skipped", "missed": "missed", @@ -2380,7 +2401,7 @@ "weightSectionTitle": "Weight", "pulseSectionTitle": "Pulse", "bmiSectionTitle": "BMI", - "complianceNoData": "No compliance data available.", + "complianceNoData": "No adherence data available.", "generate": "Generate new analysis", "moodSectionTitle": "Mood", "confidence": "Confidence", @@ -2413,7 +2434,7 @@ }, "medication": { "title": "No medications set up yet", - "description": "Set up a medication and intake compliance, drug level and titration timeline will appear here.", + "description": "Set up a medication and adherence, drug level and titration timeline will appear here.", "cta": "Add medication" }, "sleep": { @@ -3331,7 +3352,7 @@ "measurementDescription": "One reading unlocks the tile — the trend itself draws from your 5th reading.", "measurementCta": "Add measurement", "medicationTitle": "Add a medication", - "medicationDescription": "Track compliance and get reminders right when you need them.", + "medicationDescription": "Track adherence and get reminders right when you need them.", "medicationCta": "Add medication", "withingsTitle": "Connect Withings (optional)", "withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.", @@ -3386,6 +3407,8 @@ "sources": { "title": "Source priority", "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "integrationsHint": "Connect or disconnect data sources on the Integrations page.", + "integrationsHintLink": "Open integrations", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", "resetDefaults": "Reset to defaults", @@ -3459,7 +3482,7 @@ "downloadFailed": "Couldn't download the export. Please try again.", "hero": { "eyebrow": "Doctor visit", - "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication compliance and (optionally) mood, all on a few pages.", + "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication adherence and (optionally) mood, all on a few pages.", "cta": "Configure & generate report", "formatHint": "PDF · print-ready" }, @@ -3473,7 +3496,7 @@ "cards": { "doctorReport": { "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication adherence, mood." }, "measurementsCsv": { "title": "Measurements", @@ -3655,8 +3678,8 @@ "codexDisconnected": "ChatGPT connection removed.", "codexConnectionFailed": "ChatGPT connection failed. Please try again.", "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", + "rawDataOnDescription": "Aggregated metrics plus anonymized individual points from the last 30 days with time context are sent — never your name, email, or direct account identifiers.", + "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum) — no individual points and no exact time context.", "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", "regenerateInsights": "Regenerate reports", "regenerateSuccess": "Reports regenerated successfully", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach hidden.", "savedShown": "Coach visible.", "saveError": "Could not save the change. Please try again." + }, + "aboutMe": { + "title": "About me", + "description": "Personal context the Coach and the daily briefing read alongside your data.", + "placeholder": "E.g. shift work, vegetarian diet, knee injury, training for a half marathon…", + "charCount": "{used} / {max} characters", + "clear": "Clear", + "save": "Save", + "savedToast": "About-me note saved.", + "clearedToast": "About-me note cleared.", + "saveError": "Could not save. Please try again.", + "loadError": "The note could not be loaded.", + "hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} measurements synchronized", "withingsFullSyncResult": "{count} measurements fully synchronized", "withingsSyncFailed": "Sync failed", + "withingsOauthConnected": "Withings connected — the first sync starts in the background.", + "withingsOauthFailed": "Withings connection failed", + "withingsOauthError": { + "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", + "replay": "This confirmation link has already been used. Please start the connection again.", + "state": "The response from Withings did not match any open connection request. Please start the connection again.", + "expired": "The connection request expired. Start the connection again and complete the approval within a few minutes.", + "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", + "nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.", + "nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.", + "token": "The token exchange with Withings failed. Check the client ID and client secret and try again.", + "generic": "Unknown error while connecting. Please start the connection again." + }, + "integrationsSourcesHint": "When several sources report the same metric, the source priority decides which value counts.", + "integrationsSourcesHintLink": "Open source priority", "withingsSynchronize": "Synchronize", "withingsDisconnect": "Disconnect", "withingsDisconnectTitle": "Disconnect Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue token", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invites", + "description": "Registration invite links — they work even while registration is closed. The link is shown only once.", + "createLabel": "New invite:", + "createDays": "{days} days", + "createdToast": "Invite created.", + "createError": "Could not create the invite.", + "tokenTitle": "Invite link", + "tokenOnceHint": "Shown only now — copy the link or have the QR code scanned.", + "copyLink": "Copy link", + "copiedToast": "Link copied.", + "copyError": "Copy failed.", + "qrAlt": "QR code for the invite link", + "loadError": "Could not load the invites.", + "listEmpty": "No invites yet.", + "expires": "expires {date}", + "uses": "{used} / {max} used", + "createdMeta": "Created {date} by {by}", + "usedBy": "last used by {username}", + "statusActive": "Active", + "statusExpired": "Expired", + "statusExhausted": "Used up", + "deleteAria": "Delete invite", + "deleteConfirmTitle": "Delete this invite?", + "deleteConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain.", + "deleteConfirmAction": "Delete", + "cancel": "Cancel", + "deletedToast": "Invite deleted.", + "deleteError": "Could not delete the invite." + }, "userManagement": "User Management", "userName": "Name", "userEmail": "Email", @@ -4916,12 +4997,22 @@ "hourAria": "Mood reminder hour", "hourSavedToast": "Reminder time updated." }, + "coachNudge": { + "title": "Proactive Coach", + "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.", + "toggleAria": "Toggle proactive Coach nudges", + "statusOn": "Active — max. once a week", + "statusOff": "Off", + "enabledToast": "Proactive Coach nudges enabled.", + "disabledToast": "Proactive Coach nudges disabled.", + "saveError": "Could not save. Please try again later." + }, "eventMedicationReminder": "Medication reminder", "eventMedicationReminderDesc": "Sent at each scheduled dose time.", "eventMeasurementAnomaly": "Unusual measurement", "eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.", - "eventComplianceLow": "Low compliance", - "eventComplianceLowDesc": "Sent when your intake rate over the last few days drops below 80 %.", + "eventComplianceLow": "Low adherence", + "eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.", "eventWithingsSyncFailed": "Withings sync failed", "eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.", "eventSystemAlert": "System notice", @@ -5102,6 +5193,14 @@ "dailyTitle": "Log your mood", "dailyBody": "How are you feeling today?" }, + "coachNudges": { + "complianceTitle": "Your Coach has a thought on this", + "complianceBody": "Your intakes were patchy this week. Have a quick word with the Coach — maybe the plan can be simplified.", + "bpTitle": "Blood pressure elevated this week", + "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.", + "scoreTitle": "Your recovery score is dropping", + "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes." + }, "clinicianView": { "title": "Shared health record", "period": "Reporting period: {start} – {end}", diff --git a/messages/es.json b/messages/es.json index 173fa8525..6ed2968aa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea." }, + "demoBanner": { + "message": "Modo demo — los cambios no se guardan." + }, "nav": { "dashboard": "Panel", "measurements": "Mediciones", @@ -251,6 +254,7 @@ "hasAccount": "¿Ya tienes cuenta?", "loginFailed": "No se ha podido iniciar sesión", "registerFailed": "No se ha podido completar el registro", + "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", "createAccount": "Crear cuenta", "newAccount": "Crear cuenta nueva", "backToPasskey": "Volver a passkey", @@ -719,6 +723,8 @@ "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", + "complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.", + "complianceInfoLabel": "¿Qué significa el cumplimiento?", "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", @@ -1046,6 +1052,8 @@ "subline": "Revisa todo abajo antes de guardar.", "remindersLabel": "Recordatorios activados", "remindersDescription": "Push, Telegram o ntfy — según tus ajustes.", + "noChannelHint": "Los recordatorios necesitan un canal de notificaciones — ahora mismo no hay ninguno activo.", + "noChannelHintLink": "Configurar notificaciones", "multiScheduleNote": "Este medicamento tiene varios horarios. El editor detallado volverá en una versión posterior.", "short": "Listo" } @@ -1363,6 +1371,18 @@ "body": "Esta toma queda justo fuera de la ventana de {slot}. ¿Asignarla a ese horario o guardarla como entrada fuera de horario?", "keepAdHoc": "Fuera de horario", "pin": "Asignar" + }, + "pin": { + "action": "Asignar al horario de las {time}", + "release": "Quitar asignación", + "pinnedTag": "asignada", + "pinnedToast": "Toma asignada al horario", + "unpinnedToast": "Asignación quitada", + "dueContext": "prevista: {time} ({delta})", + "confirmTitle": "¿Asignar al horario de las {time}?", + "confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", + "confirmCancel": "Cancelar", + "confirmAction": "Asignar" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Cargando la gráfica…", "loadingSlowHint": "Calculando los análisis — puede tardar un momento.", + "adjustTargets": "Ajustar objetivos", "weight": "Peso", "bloodPressure": "Tensión arterial", "pulse": "Pulso", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.", - "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo y dejar que tu cuerpo se ponga al día. Tómala como una guía para dosificar el día, no como un veredicto.", - "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número, sopesando cuánto dormiste, con qué eficiencia, qué tan regular fue tu horario y el equilibrio entre sueño ligero, profundo y REM. Su objetivo es mostrar de un vistazo si la noche realmente te repuso. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", - "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto y cuánta capacidad tienes hoy para asumir carga, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo. Es una guía sobre cuánto esforzarte, no un límite.", - "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, de modo que refleja lo que resulta exigente para ti en concreto. Úsalo para equilibrar los días duros con otros más suaves y mantener un entrenamiento sostenible.", - "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado más calmado y bien regulado; una más alta es una invitación a incorporar recuperación. Es una aproximación descriptiva para detectar tendencias, no una medición clínica." + "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.", + "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", + "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.", + "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.", + "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación." }, "stats": { "label": "Estadísticas resumidas", @@ -3386,6 +3407,8 @@ "sources": { "title": "Prioridad de fuentes", "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", + "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", + "integrationsHintLink": "Abrir integraciones", "cardTitle": "Prioridad de fuentes por métrica", "help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.", "resetDefaults": "Restablecer valores predeterminados", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Conexión con ChatGPT eliminada.", "codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.", "rawData": "Enviar datos en bruto", - "rawDataOnDescription": "Se envían métricas agregadas más puntos en bruto anonimizados de los últimos 30 días. Esto incluye mediciones por métrica (por ejemplo, peso, presión arterial, pulso) con contexto temporal para que el proveedor pueda detectar patrones, valores atípicos y correlaciones de forma más fiable. No se transmiten el nombre, el correo electrónico ni los identificadores directos de la cuenta.", - "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo). Sin puntos individuales ni contexto temporal exacto. Esto preserva mejor la privacidad, pero el proveedor puede ser menos preciso con los patrones y las fluctuaciones a corto plazo.", + "rawDataOnDescription": "Se envían métricas agregadas más puntos individuales anonimizados de los últimos 30 días con contexto temporal — nunca el nombre, el correo electrónico ni los identificadores directos de la cuenta.", + "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo) — sin puntos individuales ni contexto temporal exacto.", "rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.", "regenerateInsights": "Regenerar informes", "regenerateSuccess": "Informes regenerados correctamente", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach oculto.", "savedShown": "Coach visible.", "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + }, + "aboutMe": { + "title": "Sobre mí", + "description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.", + "placeholder": "P. ej. trabajo por turnos, dieta vegetariana, lesión de rodilla, entrenamiento para una media maratón…", + "charCount": "{used} / {max} caracteres", + "clear": "Vaciar", + "save": "Guardar", + "savedToast": "Nota «Sobre mí» guardada.", + "clearedToast": "Nota «Sobre mí» eliminada.", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "loadError": "No se pudo cargar la nota.", + "hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} mediciones sincronizadas", "withingsFullSyncResult": "{count} mediciones sincronizadas por completo", "withingsSyncFailed": "Falló la sincronización", + "withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.", + "withingsOauthFailed": "Error al conectar con Withings", + "withingsOauthError": { + "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", + "replay": "Este enlace de confirmación ya se ha utilizado. Inicia la conexión de nuevo.", + "state": "La respuesta de Withings no coincide con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", + "expired": "La solicitud de conexión ha caducado. Inicia la conexión de nuevo y completa la autorización en pocos minutos.", + "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", + "nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.", + "nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.", + "token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.", + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + }, + "integrationsSourcesHint": "Si varias fuentes aportan la misma métrica, la prioridad de fuentes decide qué valor cuenta.", + "integrationsSourcesHintLink": "Abrir prioridad de fuentes", "withingsSynchronize": "Sincronizar", "withingsDisconnect": "Desconectar", "withingsDisconnectTitle": "¿Desconectar Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token de issues", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invitaciones", + "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.", + "createLabel": "Nueva invitación:", + "createDays": "{days} días", + "createdToast": "Invitación creada.", + "createError": "No se pudo crear la invitación.", + "tokenTitle": "Enlace de invitación", + "tokenOnceHint": "Se muestra solo ahora: copia el enlace o deja que escaneen el código QR.", + "copyLink": "Copiar enlace", + "copiedToast": "Enlace copiado.", + "copyError": "No se pudo copiar.", + "qrAlt": "Código QR del enlace de invitación", + "loadError": "No se pudieron cargar las invitaciones.", + "listEmpty": "Todavía no hay invitaciones.", + "expires": "caduca el {date}", + "uses": "{used} / {max} usados", + "createdMeta": "Creada el {date} por {by}", + "usedBy": "usada por última vez por {username}", + "statusActive": "Activa", + "statusExpired": "Caducada", + "statusExhausted": "Agotada", + "deleteAria": "Eliminar invitación", + "deleteConfirmTitle": "¿Eliminar esta invitación?", + "deleteConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen.", + "deleteConfirmAction": "Eliminar", + "cancel": "Cancelar", + "deletedToast": "Invitación eliminada.", + "deleteError": "No se pudo eliminar la invitación." + }, "userManagement": "Gestión de usuarios", "userName": "Nombre", "userEmail": "Correo electrónico", @@ -4916,6 +4997,16 @@ "hourAria": "Hora del recordatorio de ánimo", "hourSavedToast": "Hora del recordatorio actualizada." }, + "coachNudge": { + "title": "Coach proactivo", + "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.", + "toggleAria": "Activar o desactivar los avisos proactivos del Coach", + "statusOn": "Activo — máx. 1 vez por semana", + "statusOff": "Desactivado", + "enabledToast": "Avisos proactivos del Coach activados.", + "disabledToast": "Avisos proactivos del Coach desactivados.", + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." + }, "eventMedicationReminder": "Recordatorio de medicación", "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", "eventMeasurementAnomaly": "Medición inusual", @@ -5102,6 +5193,14 @@ "dailyTitle": "Registrar el ánimo", "dailyBody": "¿Cómo te sientes hoy?" }, + "coachNudges": { + "complianceTitle": "Tu Coach tiene algo que comentarte", + "complianceBody": "Tus tomas han sido irregulares esta semana. Habla un momento con el Coach: quizá se pueda simplificar el plan.", + "bpTitle": "Tensión elevada esta semana", + "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.", + "scoreTitle": "Tu puntuación de recuperación baja", + "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas." + }, "clinicianView": { "title": "Registro de salud compartido", "period": "Período del informe: {start} – {end}", diff --git a/messages/fr.json b/messages/fr.json index 362d7cd9d..9554d507d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne." }, + "demoBanner": { + "message": "Mode démo — les modifications ne sont pas enregistrées." + }, "nav": { "dashboard": "Tableau de bord", "measurements": "Mesures", @@ -251,6 +254,7 @@ "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", "registerFailed": "Échec de l’inscription", + "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.", "createAccount": "Créer un compte", "newAccount": "Créer un nouveau compte", "backToPasskey": "Revenir à la passkey", @@ -719,6 +723,8 @@ "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", + "complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.", + "complianceInfoLabel": "Que signifie l'observance ?", "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", @@ -1046,6 +1052,8 @@ "subline": "Vérifiez tout ci-dessous avant d’enregistrer.", "remindersLabel": "Rappels activés", "remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.", + "noChannelHint": "Les rappels nécessitent un canal de notification — aucun n'est actif pour le moment.", + "noChannelHintLink": "Configurer les notifications", "multiScheduleNote": "Ce médicament a plusieurs horaires. L’éditeur détaillé reviendra dans une version ultérieure.", "short": "Fini" } @@ -1363,6 +1371,18 @@ "body": "Cette prise se situe juste en dehors de la fenêtre de {slot}. La rattacher à ce créneau ou la conserver comme entrée hors planning ?", "keepAdHoc": "Hors planning", "pin": "Rattacher" + }, + "pin": { + "action": "Rattacher au créneau de {time}", + "release": "Détacher du créneau", + "pinnedTag": "rattachée", + "pinnedToast": "Prise rattachée au créneau", + "unpinnedToast": "Prise détachée du créneau", + "dueContext": "prévue à {time} ({delta})", + "confirmTitle": "Rattacher au créneau de {time} ?", + "confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.", + "confirmCancel": "Annuler", + "confirmAction": "Rattacher" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Chargement du graphique…", "loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.", + "adjustTargets": "Ajuster les valeurs cibles", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.", - "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied et à laisser votre corps rattraper. Voyez-le comme un repère pour doser la journée, pas comme un verdict.", - "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre, en pesant la durée de votre sommeil, son efficacité, la régularité de vos horaires et l'équilibre entre sommeil léger, profond et paradoxal. Il vise à montrer d'un coup d'œil si la nuit vous a vraiment restauré. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", - "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps et la capacité dont vous disposez aujourd'hui pour encaisser de la charge, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore. C'est un repère sur l'intensité à adopter, pas une limite.", - "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel et reflète ainsi ce qui est exigeant pour vous en particulier. Servez-vous-en pour équilibrer les journées intenses et les plus douces, et garder un entraînement tenable.", - "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état plus calme et bien régulé ; un score plus élevé invite à prévoir de la récupération. C'est une approximation descriptive pour repérer des tendances, pas une mesure clinique." + "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.", + "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", + "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.", + "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.", + "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération." }, "stats": { "label": "Statistiques récapitulatives", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorité des sources", "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", + "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", + "integrationsHintLink": "Ouvrir les intégrations", "cardTitle": "Priorité des sources par métrique", "help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.", "resetDefaults": "Rétablir les valeurs par défaut", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Connexion à ChatGPT supprimée.", "codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.", "rawData": "Envoyer les données brutes", - "rawDataOnDescription": "Des métriques agrégées sont envoyées, ainsi que des points bruts anonymisés des 30 derniers jours. Cela inclut des mesures par métrique (par exemple poids, tension, pouls) avec un contexte temporel, afin que le fournisseur puisse détecter plus fiablement les tendances, les valeurs aberrantes et les corrélations. Le nom, l’e-mail et les identifiants de compte directs ne sont pas transmis.", - "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum). Aucun point individuel ni contexte temporel exact. Cela préserve mieux la vie privée, mais le fournisseur peut être moins précis pour les tendances et fluctuations à court terme.", + "rawDataOnDescription": "Des métriques agrégées et des points individuels anonymisés des 30 derniers jours avec contexte temporel sont envoyés — jamais le nom, l’e-mail ni les identifiants de compte directs.", + "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum) — aucun point individuel ni contexte temporel exact.", "rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.", "regenerateInsights": "Régénérer les rapports", "regenerateSuccess": "Rapports régénérés", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach masqué.", "savedShown": "Coach visible.", "saveError": "Impossible d'enregistrer la modification. Veuillez réessayer." + }, + "aboutMe": { + "title": "À propos de moi", + "description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.", + "placeholder": "Ex. travail posté, régime végétarien, blessure au genou, entraînement pour un semi-marathon…", + "charCount": "{used} / {max} caractères", + "clear": "Vider", + "save": "Enregistrer", + "savedToast": "Note « À propos de moi » enregistrée.", + "clearedToast": "Note « À propos de moi » supprimée.", + "saveError": "Impossible d'enregistrer. Veuillez réessayer.", + "loadError": "Impossible de charger la note.", + "hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} mesures synchronisées", "withingsFullSyncResult": "{count} mesures entièrement synchronisées", "withingsSyncFailed": "Échec de la synchronisation", + "withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.", + "withingsOauthFailed": "Échec de la connexion Withings", + "withingsOauthError": { + "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur manquait. Relancez la connexion dans le même navigateur.", + "replay": "Ce lien de confirmation a déjà été utilisé. Relancez la connexion.", + "state": "La réponse de Withings ne correspond à aucune demande de connexion en cours. Relancez la connexion.", + "expired": "La demande de connexion a expiré. Relancez la connexion et terminez l'autorisation en quelques minutes.", + "cross_user": "La réponse appartient à une demande d'un autre compte. Relancez la connexion depuis votre propre compte.", + "nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.", + "nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.", + "token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.", + "generic": "Erreur inconnue lors de la connexion. Relancez la connexion." + }, + "integrationsSourcesHint": "Quand plusieurs sources fournissent la même métrique, la priorité des sources décide quelle valeur compte.", + "integrationsSourcesHintLink": "Ouvrir la priorité des sources", "withingsSynchronize": "Synchroniser", "withingsDisconnect": "Déconnecter", "withingsDisconnectTitle": "Déconnecter Withings ?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Jeton d'issue", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invitations", + "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.", + "createLabel": "Nouvelle invitation :", + "createDays": "{days} jours", + "createdToast": "Invitation créée.", + "createError": "Impossible de créer l'invitation.", + "tokenTitle": "Lien d'invitation", + "tokenOnceHint": "Affiché uniquement maintenant — copiez le lien ou faites scanner le code QR.", + "copyLink": "Copier le lien", + "copiedToast": "Lien copié.", + "copyError": "Échec de la copie.", + "qrAlt": "Code QR du lien d'invitation", + "loadError": "Impossible de charger les invitations.", + "listEmpty": "Aucune invitation pour l'instant.", + "expires": "expire le {date}", + "uses": "{used} / {max} utilisés", + "createdMeta": "Créée le {date} par {by}", + "usedBy": "utilisée en dernier par {username}", + "statusActive": "Active", + "statusExpired": "Expirée", + "statusExhausted": "Épuisée", + "deleteAria": "Supprimer l'invitation", + "deleteConfirmTitle": "Supprimer cette invitation ?", + "deleteConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés.", + "deleteConfirmAction": "Supprimer", + "cancel": "Annuler", + "deletedToast": "Invitation supprimée.", + "deleteError": "Impossible de supprimer l'invitation." + }, "userManagement": "Gestion des utilisateurs", "userName": "Nom", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Heure du rappel d’humeur", "hourSavedToast": "Heure du rappel mise à jour." }, + "coachNudge": { + "title": "Coach proactif", + "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.", + "toggleAria": "Activer ou désactiver les rappels proactifs du Coach", + "statusOn": "Actif — max. 1 fois par semaine", + "statusOff": "Désactivé", + "enabledToast": "Rappels proactifs du Coach activés.", + "disabledToast": "Rappels proactifs du Coach désactivés.", + "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard." + }, "eventMedicationReminder": "Rappel de médicament", "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", "eventMeasurementAnomaly": "Mesure inhabituelle", @@ -5102,6 +5193,14 @@ "dailyTitle": "Enregistrer votre humeur", "dailyBody": "Comment vous sentez-vous aujourd’hui ?" }, + "coachNudges": { + "complianceTitle": "Votre Coach a une remarque à ce sujet", + "complianceBody": "Vos prises ont été irrégulières cette semaine. Échangez avec le Coach — le plan peut peut-être être simplifié.", + "bpTitle": "Tension élevée cette semaine", + "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.", + "scoreTitle": "Votre score de récupération baisse", + "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles." + }, "clinicianView": { "title": "Dossier de santé partagé", "period": "Période couverte : {start} – {end}", diff --git a/messages/it.json b/messages/it.json index eb842daf4..27da1b873 100644 --- a/messages/it.json +++ b/messages/it.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online." }, + "demoBanner": { + "message": "Modalità demo — le modifiche non vengono salvate." + }, "nav": { "dashboard": "Dashboard", "measurements": "Misurazioni", @@ -251,6 +254,7 @@ "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", "registerFailed": "Registrazione non riuscita", + "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.", "createAccount": "Crea account", "newAccount": "Crea un nuovo account", "backToPasskey": "Torna a passkey", @@ -719,6 +723,8 @@ "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", + "complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.", + "complianceInfoLabel": "Cosa significa aderenza?", "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", @@ -1046,6 +1052,8 @@ "subline": "Controlla tutto qui sotto prima di salvare.", "remindersLabel": "Promemoria attivi", "remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.", + "noChannelHint": "I promemoria richiedono un canale di notifica — al momento nessuno è attivo.", + "noChannelHintLink": "Configura le notifiche", "multiScheduleNote": "Questo farmaco ha più orari. L’editor dettagliato tornerà in una versione successiva.", "short": "Fatto" } @@ -1363,6 +1371,18 @@ "body": "Questa assunzione cade appena fuori dalla finestra di {slot}. Attribuirla a quello slot o tenerla come voce fuori programma?", "keepAdHoc": "Fuori programma", "pin": "Attribuisci" + }, + "pin": { + "action": "Attribuisci allo slot delle {time}", + "release": "Rimuovi attribuzione", + "pinnedTag": "attribuita", + "pinnedToast": "Assunzione attribuita allo slot", + "unpinnedToast": "Attribuzione rimossa", + "dueContext": "prevista alle {time} ({delta})", + "confirmTitle": "Attribuire allo slot delle {time}?", + "confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", + "confirmCancel": "Annulla", + "confirmAction": "Attribuisci" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Caricamento del grafico…", "loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.", + "adjustTargets": "Regola i valori target", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Pulsazioni", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.", - "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare e lasciare che il corpo recuperi. Consideralo una guida per dosare la giornata, non un verdetto.", - "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero, valutando quanto hai dormito, con quale efficienza, quanto regolari sono stati i tuoi orari e l'equilibrio tra sonno leggero, profondo e REM. Serve a mostrare a colpo d'occhio se la notte ti ha davvero ristorato. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", - "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso e quanta capacità hai oggi per sostenere il carico, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo. È una guida su quanto impegnarti, non un limite.", - "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale e riflette così ciò che è impegnativo per te in particolare. Usalo per bilanciare le giornate intense con altre più leggere e mantenere un allenamento sostenibile.", - "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato più calmo e ben regolato; uno più alto è un invito a inserire del recupero. È un'approssimazione descrittiva per notare le tendenze, non una misurazione clinica." + "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.", + "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", + "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.", + "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.", + "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero." }, "stats": { "label": "Statistiche riepilogative", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorità delle fonti", "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", + "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", + "integrationsHintLink": "Apri le integrazioni", "cardTitle": "Priorità delle fonti per metrica", "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", "resetDefaults": "Ripristina i valori predefiniti", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Connessione a ChatGPT rimossa.", "codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.", "rawData": "Invia dati grezzi", - "rawDataOnDescription": "Vengono inviate metriche aggregate più punti grezzi anonimizzati degli ultimi 30 giorni. Ciò include misurazioni per singola metrica (ad esempio peso, pressione, polso) con contesto temporale, in modo che il provider possa rilevare in modo più affidabile schemi, valori anomali e correlazioni. Nome, e-mail e identificatori diretti dell’account non vengono trasmessi.", - "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo). Nessun punto individuale e nessun contesto temporale preciso. Questo tutela meglio la privacy, ma il provider può essere meno preciso su schemi e oscillazioni a breve termine.", + "rawDataOnDescription": "Vengono inviate metriche aggregate più punti individuali anonimizzati degli ultimi 30 giorni con contesto temporale — mai nome, e-mail o identificatori diretti dell’account.", + "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo) — nessun punto individuale e nessun contesto temporale preciso.", "rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.", "regenerateInsights": "Rigenera i report", "regenerateSuccess": "Report rigenerati correttamente", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach nascosto.", "savedShown": "Coach visibile.", "saveError": "Impossibile salvare la modifica. Riprova." + }, + "aboutMe": { + "title": "Su di me", + "description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.", + "placeholder": "Es. lavoro a turni, dieta vegetariana, infortunio al ginocchio, allenamento per una mezza maratona…", + "charCount": "{used} / {max} caratteri", + "clear": "Svuota", + "save": "Salva", + "savedToast": "Nota «Su di me» salvata.", + "clearedToast": "Nota «Su di me» eliminata.", + "saveError": "Impossibile salvare. Riprova.", + "loadError": "Impossibile caricare la nota.", + "hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} misurazioni sincronizzate", "withingsFullSyncResult": "{count} misurazioni sincronizzate completamente", "withingsSyncFailed": "Sincronizzazione non riuscita", + "withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.", + "withingsOauthFailed": "Collegamento a Withings non riuscito", + "withingsOauthError": { + "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Avvia di nuovo il collegamento nello stesso browser.", + "replay": "Questo link di conferma è già stato utilizzato. Avvia di nuovo il collegamento.", + "state": "La risposta di Withings non corrisponde a nessuna richiesta di collegamento aperta. Avvia di nuovo il collegamento.", + "expired": "La richiesta di collegamento è scaduta. Avvia di nuovo il collegamento e completa l'autorizzazione entro pochi minuti.", + "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia il collegamento dal tuo account.", + "nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.", + "nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.", + "token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.", + "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento." + }, + "integrationsSourcesHint": "Se più fonti forniscono la stessa metrica, la priorità delle fonti decide quale valore conta.", + "integrationsSourcesHintLink": "Apri la priorità delle fonti", "withingsSynchronize": "Sincronizza", "withingsDisconnect": "Disconnetti", "withingsDisconnectTitle": "Disconnettere Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token delle issue", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Inviti", + "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.", + "createLabel": "Nuovo invito:", + "createDays": "{days} giorni", + "createdToast": "Invito creato.", + "createError": "Impossibile creare l'invito.", + "tokenTitle": "Link di invito", + "tokenOnceHint": "Mostrato solo adesso: copia il link o fai scansionare il codice QR.", + "copyLink": "Copia link", + "copiedToast": "Link copiato.", + "copyError": "Copia non riuscita.", + "qrAlt": "Codice QR del link di invito", + "loadError": "Impossibile caricare gli inviti.", + "listEmpty": "Ancora nessun invito.", + "expires": "scade il {date}", + "uses": "{used} / {max} usati", + "createdMeta": "Creato il {date} da {by}", + "usedBy": "usato per ultimo da {username}", + "statusActive": "Attivo", + "statusExpired": "Scaduto", + "statusExhausted": "Esaurito", + "deleteAria": "Elimina invito", + "deleteConfirmTitle": "Eliminare questo invito?", + "deleteConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano.", + "deleteConfirmAction": "Elimina", + "cancel": "Annulla", + "deletedToast": "Invito eliminato.", + "deleteError": "Impossibile eliminare l'invito." + }, "userManagement": "Gestione utenti", "userName": "Nome", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Ora del promemoria dell’umore", "hourSavedToast": "Ora del promemoria aggiornata." }, + "coachNudge": { + "title": "Coach proattivo", + "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.", + "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach", + "statusOn": "Attivo — max 1 volta a settimana", + "statusOff": "Disattivato", + "enabledToast": "Avvisi proattivi del Coach attivati.", + "disabledToast": "Avvisi proattivi del Coach disattivati.", + "saveError": "Impossibile salvare. Riprova più tardi." + }, "eventMedicationReminder": "Promemoria farmaco", "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", "eventMeasurementAnomaly": "Misurazione insolita", @@ -5102,6 +5193,14 @@ "dailyTitle": "Registra il tuo umore", "dailyBody": "Come ti senti oggi?" }, + "coachNudges": { + "complianceTitle": "Il tuo Coach ha un'osservazione", + "complianceBody": "Le tue assunzioni sono state discontinue questa settimana. Parla un attimo con il Coach: forse il piano si può semplificare.", + "bpTitle": "Pressione elevata questa settimana", + "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.", + "scoreTitle": "Il tuo punteggio di recupero sta calando", + "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause." + }, "clinicianView": { "title": "Cartella sanitaria condivisa", "period": "Periodo del report: {start} – {end}", diff --git a/messages/pl.json b/messages/pl.json index 65f77219c..e10ed0e5d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online." }, + "demoBanner": { + "message": "Tryb demo — zmiany nie są zapisywane." + }, "nav": { "dashboard": "Panel", "measurements": "Pomiary", @@ -251,6 +254,7 @@ "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", "registerFailed": "Rejestracja nie powiodła się", + "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.", "createAccount": "Utwórz konto", "newAccount": "Utwórz nowe konto", "backToPasskey": "Wróć do passkey", @@ -719,6 +723,8 @@ "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", + "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", + "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", "complianceDoses": "{count} dawek", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", @@ -1046,6 +1052,8 @@ "subline": "Sprawdź wszystko poniżej przed zapisaniem.", "remindersLabel": "Przypomnienia włączone", "remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.", + "noChannelHint": "Przypomnienia wymagają kanału powiadomień — obecnie żaden nie jest aktywny.", + "noChannelHintLink": "Skonfiguruj powiadomienia", "multiScheduleNote": "Ten lek ma wiele harmonogramów. Szczegółowy edytor wróci w kolejnej wersji.", "short": "Gotowe" } @@ -1363,6 +1371,18 @@ "body": "To przyjęcie wypada tuż poza oknem {slot}. Przypisać je do tego slotu czy zachować jako wpis poza planem?", "keepAdHoc": "Poza planem", "pin": "Przypisz" + }, + "pin": { + "action": "Przypisz do slotu {time}", + "release": "Usuń przypisanie", + "pinnedTag": "przypisana", + "pinnedToast": "Dawka przypisana do slotu", + "unpinnedToast": "Przypisanie usunięte", + "dueContext": "termin: {time} ({delta})", + "confirmTitle": "Przypisać do slotu {time}?", + "confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", + "confirmCancel": "Anuluj", + "confirmAction": "Przypisz" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Ładowanie wykresu…", "loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.", + "adjustTargets": "Dostosuj wartości docelowe", "weight": "Waga", "bloodPressure": "Ciśnienie tętnicze", "pulse": "Tętno", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.", - "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo i dać ciału nadrobić zaległości. Traktuj go jako wskazówkę do rozłożenia sił w ciągu dnia, a nie jako wyrok.", - "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie, biorąc pod uwagę, jak długo i jak efektywnie spałeś, jak regularne były Twoje pory oraz proporcje snu lekkiego, głębokiego i REM. Ma pokazać na pierwszy rzut oka, czy noc naprawdę Cię zregenerowała. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", - "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało i ile masz dziś zapasu, by przyjąć obciążenie — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje. To wskazówka, jak mocno się angażować, a nie sztywna granica.", - "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, więc odzwierciedla to, co jest wymagające właśnie dla Ciebie. Wykorzystaj je, by równoważyć ciężkie dni lżejszymi i utrzymać trening na zrównoważonym poziomie.", - "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojniejszy, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację. To opisowe przybliżenie pozwalające dostrzegać trendy, a nie pomiar kliniczny." + "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.", + "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", + "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.", + "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.", + "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację." }, "stats": { "label": "Statystyki podsumowujące", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorytet źródeł", "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", + "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", + "integrationsHintLink": "Otwórz integracje", "cardTitle": "Priorytet źródeł według metryki", "help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.", "resetDefaults": "Przywróć wartości domyślne", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Połączenie z ChatGPT usunięte.", "codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.", "rawData": "Wysyłaj surowe dane", - "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane surowe punkty z ostatnich 30 dni. Obejmuje to pomiary dla poszczególnych metryk (np. waga, ciśnienie, tętno) z kontekstem czasowym, aby dostawca mógł rzetelniej wykrywać wzorce, wartości odstające i korelacje. Imię, adres e-mail i bezpośrednie identyfikatory konta nie są przesyłane.", - "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum). Bez pojedynczych punktów i bez dokładnego kontekstu czasowego. Lepiej chroni to prywatność, ale dostawca może być mniej precyzyjny w przypadku wzorców i krótkoterminowych wahań.", + "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane pojedyncze punkty z ostatnich 30 dni z kontekstem czasowym — nigdy imię, adres e-mail ani bezpośrednie identyfikatory konta.", + "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum) — bez pojedynczych punktów i bez dokładnego kontekstu czasowego.", "rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.", "regenerateInsights": "Wygeneruj raporty ponownie", "regenerateSuccess": "Raporty wygenerowane ponownie", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach ukryty.", "savedShown": "Coach widoczny.", "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + }, + "aboutMe": { + "title": "O mnie", + "description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.", + "placeholder": "Np. praca zmianowa, dieta wegetariańska, kontuzja kolana, trening do półmaratonu…", + "charCount": "{used} / {max} znaków", + "clear": "Wyczyść", + "save": "Zapisz", + "savedToast": "Notatka „O mnie” zapisana.", + "clearedToast": "Notatka „O mnie” usunięta.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "loadError": "Nie udało się wczytać notatki.", + "hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} pomiarów zsynchronizowano", "withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano", "withingsSyncFailed": "Synchronizacja nie powiodła się", + "withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.", + "withingsOauthFailed": "Połączenie z Withings nie powiodło się", + "withingsOauthError": { + "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", + "replay": "Ten link potwierdzający został już użyty. Rozpocznij połączenie ponownie.", + "state": "Odpowiedź Withings nie pasuje do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "expired": "Żądanie połączenia wygasło. Rozpocznij połączenie ponownie i dokończ autoryzację w ciągu kilku minut.", + "cross_user": "Odpowiedź należy do żądania innego konta. Rozpocznij połączenie ponownie z własnego konta.", + "nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.", + "nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.", + "token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.", + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + }, + "integrationsSourcesHint": "Gdy kilka źródeł dostarcza tę samą metrykę, priorytet źródeł decyduje, która wartość się liczy.", + "integrationsSourcesHintLink": "Otwórz priorytet źródeł", "withingsSynchronize": "Synchronizuj", "withingsDisconnect": "Rozłącz", "withingsDisconnectTitle": "Rozłączyć Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token zgłoszeń", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Zaproszenia", + "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.", + "createLabel": "Nowe zaproszenie:", + "createDays": "{days} dni", + "createdToast": "Zaproszenie utworzone.", + "createError": "Nie udało się utworzyć zaproszenia.", + "tokenTitle": "Link z zaproszeniem", + "tokenOnceHint": "Wyświetlane tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", + "copyLink": "Kopiuj link", + "copiedToast": "Link skopiowany.", + "copyError": "Kopiowanie nie powiodło się.", + "qrAlt": "Kod QR linku z zaproszeniem", + "loadError": "Nie udało się wczytać zaproszeń.", + "listEmpty": "Brak zaproszeń.", + "expires": "wygasa {date}", + "uses": "{used} / {max} wykorzystane", + "createdMeta": "Utworzono {date} przez {by}", + "usedBy": "ostatnio użyte przez {username}", + "statusActive": "Aktywne", + "statusExpired": "Wygasłe", + "statusExhausted": "Wyczerpane", + "deleteAria": "Usuń zaproszenie", + "deleteConfirmTitle": "Usunąć to zaproszenie?", + "deleteConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną.", + "deleteConfirmAction": "Usuń", + "cancel": "Anuluj", + "deletedToast": "Zaproszenie usunięte.", + "deleteError": "Nie udało się usunąć zaproszenia." + }, "userManagement": "Zarządzanie użytkownikami", "userName": "Nazwa", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Godzina przypomnienia o nastroju", "hourSavedToast": "Godzina przypomnienia zaktualizowana." }, + "coachNudge": { + "title": "Proaktywny Coach", + "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.", + "toggleAria": "Przełącz proaktywne podpowiedzi Coacha", + "statusOn": "Aktywne — maks. 1 raz w tygodniu", + "statusOff": "Wyłączone", + "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", + "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie później." + }, "eventMedicationReminder": "Przypomnienie o leku", "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", "eventMeasurementAnomaly": "Nietypowy pomiar", @@ -5102,6 +5193,14 @@ "dailyTitle": "Zapisz nastrój", "dailyBody": "Jak się dzisiaj czujesz?" }, + "coachNudges": { + "complianceTitle": "Twój Coach ma na ten temat przemyślenie", + "complianceBody": "W tym tygodniu Twoje dawki były przyjmowane nieregularnie. Porozmawiaj chwilę z Coachem — może plan da się uprościć.", + "bpTitle": "Podwyższone ciśnienie w tym tygodniu", + "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.", + "scoreTitle": "Twój wskaźnik regeneracji spada", + "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny." + }, "clinicianView": { "title": "Udostępniona dokumentacja zdrowotna", "period": "Okres raportu: {start} – {end}", diff --git a/package.json b/package.json index 140dd5235..4b0f75384 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "p-limit": "^7.3.0", "pg-boss": "^12.18.2", "prisma": "^7.8.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", @@ -99,6 +100,7 @@ "@testcontainers/postgresql": "^12.0.1", "@types/node": "^25.9.2", "@types/pg": "^8.20.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f6dcc29..7e4d11f33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: prisma: specifier: ^7.8.0 version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -153,6 +156,9 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19 version: 19.2.14 @@ -2851,6 +2857,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} @@ -3430,6 +3439,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -3481,6 +3494,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3685,6 +3701,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -3752,6 +3772,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -4161,6 +4184,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4846,6 +4873,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5199,6 +5230,10 @@ packages: oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5207,10 +5242,18 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5353,6 +5396,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5528,6 +5575,11 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -5689,6 +5741,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -5799,6 +5854,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -6411,6 +6469,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -6434,6 +6495,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6469,6 +6534,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6486,10 +6554,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -9227,6 +9303,10 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.9.2 + '@types/raf@3.4.3': optional: true @@ -9857,6 +9937,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001792: {} caniuse-lite@1.0.30001793: {} @@ -9906,6 +9988,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10077,6 +10165,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} dedent@1.7.2: {} @@ -10122,6 +10212,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -10761,6 +10853,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -11387,6 +11484,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11778,6 +11879,10 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11786,10 +11891,16 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pako@2.1.0: {} @@ -11913,6 +12024,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -12046,6 +12159,12 @@ snapshots: pvutils@1.1.5: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -12280,6 +12399,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -12427,6 +12548,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -13197,6 +13320,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -13222,6 +13347,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -13247,6 +13378,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13255,8 +13388,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql new file mode 100644 index 000000000..1baec338b --- /dev/null +++ b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql @@ -0,0 +1,16 @@ +-- v1.15.20 — provenance of an intake row's slot binding. +-- +-- An off-window take can now be deliberately pinned onto a scheduled slot +-- ("diesem Slot zuordnen"). The column records HOW the row's `scheduled_for` +-- binding came to be: AUTO = window-band attribution (or a skip / pending +-- mint anchored on its slot), USER_PIN = a deliberate user pin. The read +-- ledger binds USER_PIN rows by their stored anchor (like skips) instead of +-- re-running takenAt-band attribution, so a pinned take outside the late +-- tail stays on its slot — rendered taken-late, never on-time-washed. +-- +-- Additive + non-destructive: a new NOT NULL column with a default, no +-- backfill needed. Existing rows read AUTO. +CREATE TYPE "intake_attribution_source" AS ENUM ('AUTO', 'USER_PIN'); + +ALTER TABLE "medication_intake_events" + ADD COLUMN "attribution_source" "intake_attribution_source" NOT NULL DEFAULT 'AUTO'; diff --git a/prisma/migrations/0140_v11520_user_health_profile/migration.sql b/prisma/migrations/0140_v11520_user_health_profile/migration.sql new file mode 100644 index 000000000..20191dfd7 --- /dev/null +++ b/prisma/migrations/0140_v11520_user_health_profile/migration.sql @@ -0,0 +1,22 @@ +-- v1.15.20 — user-authored "about me" self-description for the AI surfaces. +-- +-- 1:1 with users, lazily created on first save. The free text is encrypted +-- at rest (AES-256-GCM through the shared Bytes codec, the CoachFact +-- precedent) — `about_me_encrypted` carries the `"."` +-- ciphertext as UTF-8 bytes and is NULL when the user cleared (or never +-- wrote) the text. Additive + non-destructive. +CREATE TABLE "user_health_profiles" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "about_me_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_health_profiles_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_health_profiles_user_id_key" ON "user_health_profiles"("user_id"); + +ALTER TABLE "user_health_profiles" + ADD CONSTRAINT "user_health_profiles_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0141_v11520_invite_tokens/migration.sql b/prisma/migrations/0141_v11520_invite_tokens/migration.sql new file mode 100644 index 000000000..5b6947b6c --- /dev/null +++ b/prisma/migrations/0141_v11520_invite_tokens/migration.sql @@ -0,0 +1,32 @@ +-- v1.15.20 — registration invite tokens. +-- +-- Admits a signup while open registration is disabled. Only the +-- HMAC-SHA256 hash of the raw token is stored (the ApiToken scheme); +-- `expires_at` is mandatory (route-capped at 30 days), `uses`/`max_uses` +-- carry the consumption budget, `used_by` tracks the most recent consumer +-- and survives account deletion as NULL. Additive + non-destructive. +CREATE TABLE "invite_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "used_by" TEXT, + "max_uses" INTEGER NOT NULL DEFAULT 1, + "uses" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "invite_tokens_token_hash_key" ON "invite_tokens"("token_hash"); + +CREATE INDEX "invite_tokens_expires_at_idx" ON "invite_tokens"("expires_at"); + +ALTER TABLE "invite_tokens" + ADD CONSTRAINT "invite_tokens_created_by_fkey" + FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "invite_tokens" + ADD CONSTRAINT "invite_tokens_used_by_fkey" + FOREIGN KEY ("used_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbd24fb9e..3fb6d10a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -511,6 +511,16 @@ model User { // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + // v1.15.20 — user-authored "about me" context for the AI surfaces + // (1:1, encrypted free text). See `UserHealthProfile`. + healthProfile UserHealthProfile? + // v1.15.20 — registration invite tokens (closed-registration flow). + // `InviteCreator` = admin who minted the invite; `InviteConsumer` = + // the account that registered through it (last consumer wins on a + // multi-use invite). + invitesCreated InviteToken[] @relation("InviteCreator") + invitesUsed InviteToken[] @relation("InviteConsumer") + @@map("users") } @@ -1428,6 +1438,17 @@ enum IntakeSource { @@map("intake_source") } +/// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks +/// a deliberate "diesem Slot zuordnen" decision on an off-window take; the +/// ledger then binds the row by its `scheduledFor` anchor and renders it +/// as taken-late + "zugeordnet" (no compliance flattering, unpinnable). +enum IntakeAttributionSource { + AUTO + USER_PIN + + @@map("intake_attribution_source") +} + enum ReminderPhase { GREEN YELLOW @@ -1479,6 +1500,15 @@ model MedicationIntakeEvent { /// legacy row defaults to false (a user skip or a live pending row, /// neither of which is an auto-miss). autoMissed Boolean @default(false) @map("auto_missed") + /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. + /// `AUTO` (default) = the write path attributed the take by window-band + /// membership (or the row is a skip / pending mint anchored on its slot). + /// `USER_PIN` = the user deliberately pinned an off-window take onto a + /// named slot ("diesem Slot zuordnen"). The read ledger binds USER_PIN + /// rows by their `scheduledFor` anchor (like skips) instead of re-running + /// takenAt-band attribution, so a pinned take outside the late tail stays + /// on its slot — visibly marked, counted late, never on-time-washed. + attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") source IntakeSource @default(WEB) idempotencyKey String? @unique @map("idempotency_key") createdAt DateTime @default(now()) @map("created_at") @@ -1800,6 +1830,35 @@ model ApiToken { @@map("api_tokens") } +// v1.15.20 — registration invite tokens. When the operator has disabled +// open registration (`AppSettings.registrationEnabled = false`), a valid, +// unexpired invite token still admits a signup. The raw `hlv_` token +// is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by +// `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is +// stored. `expiresAt` is mandatory and capped at 30 days by the admin +// route. `uses`/`maxUses` make multi-use invites possible; the register +// path consumes a use with an atomic guarded increment so a concurrent +// signup can never overshoot `maxUses`. `usedBy` tracks the most recent +// consumer (informational; SetNull on account deletion so the invite +// audit row outlives the account). +model InviteToken { + id String @id @default(cuid()) + tokenHash String @unique @map("token_hash") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + usedBy String? @map("used_by") + maxUses Int @default(1) @map("max_uses") + uses Int @default(0) + + creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) + consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull) + + @@index([expiresAt]) + @@map("invite_tokens") +} + // ─── Refresh Tokens (native client short-lived access + rotating refresh) ─ model RefreshToken { @@ -3007,6 +3066,28 @@ model CoachFact { @@map("coach_facts") } +// v1.15.20 — user-authored "about me" self-description the AI surfaces +// (Coach system prompt + daily-briefing user prompt) inject as a clearly +// delimited, user-provided context block. 1:1 with User and lazily +// created on first save so existing accounts pay nothing. The free text +// is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly +// like `CoachFact.factEncrypted`) — it is unstructured health prose and +// must never land in the table as plaintext. A cleared profile keeps the +// row with a NULL payload so `updatedAt` documents the deletion instant. +model UserHealthProfile { + id String @id @default(cuid()) + userId String @unique @map("user_id") + // Free text, hard-capped at 4 000 chars by the route's Zod schema + // BEFORE encryption. NULL = cleared / never written. + aboutMeEncrypted Bytes? @map("about_me_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_health_profiles") +} + // Per-user, per-day token-spend ledger for the AI Coach. The dispatcher // reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before // hitting any provider, so a runaway client cannot drain the operator's diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 304cbf512..fec6b7113 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -31,6 +31,7 @@ import { LoginOverviewSection } from "@/components/admin/login-overview-section" import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; import type { AdminSectionSlug } from "@/components/admin/section-slugs"; @@ -138,7 +139,12 @@ export function AdminSectionRenderer({ title={t("admin.section.users.title")} subtitle={t("admin.section.users.subtitle")} > - +
    + + {/* v1.15.20 — registration invites live with the accounts + they admit. */} + +
    ); case "api-tokens": diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 000000000..da5fcbe78 --- /dev/null +++ b/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * DELETE /api/admin/invites/{id} — revoke a registration invite. + * + * v1.15.20 — hard delete: an invite row carries no health data and no + * plaintext secret, and the audit log already records mint + revoke, so + * keeping a tombstone would only grow the table. Idempotent — deleting + * an unknown id returns `{ deleted: false }` rather than 404 so a + * double-tap in the admin UI never surfaces an error. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { user } = await requireAdmin(); + const { id } = await params; + + const { count } = await prisma.inviteToken.deleteMany({ where: { id } }); + const deleted = count > 0; + + if (deleted) { + await auditLog("admin.invite.deleted", { + userId: user.id, + ipAddress: getClientIp(req), + details: { inviteId: id }, + }); + } + + annotate({ + action: { name: "admin.invite.deleted" }, + meta: { deleted }, + }); + + return apiSuccess({ deleted }); + }, +); diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts new file mode 100644 index 000000000..92b6a1829 --- /dev/null +++ b/src/app/api/admin/invites/route.ts @@ -0,0 +1,110 @@ +/** + * GET /api/admin/invites — list all registration invites. + * POST /api/admin/invites — mint a new invite token. + * + * v1.15.20 — closed-registration invite flow. The raw `hlv_` token + * appears EXACTLY ONCE: in the POST response, alongside the composed + * registration URL. Only the HMAC-SHA256 hash is persisted (the + * `ApiToken` scheme), so neither the GET list nor a database leak can + * reproduce a usable invite. + * + * Both verbs are `requireAdmin()` — cookie-only by construction; a + * Bearer token can never mint or enumerate invites. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; +import { + buildInviteUrl, + generateInviteToken, +} from "@/lib/auth/invite-token"; +import { inviteCreateSchema } from "@/lib/validations/invite"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.invites.list" } }); + + const invites = await prisma.inviteToken.findMany({ + select: { + id: true, + createdAt: true, + expiresAt: true, + usedAt: true, + uses: true, + maxUses: true, + creator: { select: { id: true, username: true } }, + consumer: { select: { id: true, username: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return apiSuccess(invites); +}); + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAdmin(); + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = inviteCreateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { expiresInDays, maxUses } = parsed.data; + const rawToken = generateInviteToken(); + const expiresAt = new Date( + Date.now() + expiresInDays * 24 * 60 * 60 * 1000, + ); + + const invite = await prisma.inviteToken.create({ + data: { + tokenHash: hashToken(rawToken), + createdBy: user.id, + expiresAt, + maxUses, + }, + select: { + id: true, + createdAt: true, + expiresAt: true, + uses: true, + maxUses: true, + }, + }); + + // The audit row carries metadata only — never the raw token. + await auditLog("admin.invite.created", { + userId: user.id, + ipAddress: getClientIp(req), + details: { inviteId: invite.id, expiresInDays, maxUses }, + }); + + annotate({ + action: { name: "admin.invite.created" }, + meta: { expiresInDays, maxUses }, + }); + + return apiSuccess( + { + ...invite, + // Shown exactly once. Neither value is persisted in plaintext. + token: rawToken, + url: buildInviteUrl(rawToken, req.url), + }, + 201, + ); +}); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index b6ba3810f..3e8d598b9 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -91,6 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -111,6 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -133,6 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); }); @@ -166,6 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -175,6 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -188,11 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, changed: ["medication"], }), @@ -254,6 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -279,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -288,6 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -316,6 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3a3080207..d287a80a7 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + consumeInviteToken, + recordInviteConsumer, +} from "@/lib/auth/invite-token"; import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; @@ -57,10 +61,6 @@ export const POST = apiHandler(async (request: NextRequest) => { } catch { // Table may not exist yet; allow registration } - if (!registrationEnabled) { - return apiError("Registration is disabled", 403); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); @@ -73,7 +73,21 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { email, username, password, timezone: timezoneInput } = parsed.data; + const { + email, + username, + password, + timezone: timezoneInput, + inviteToken, + } = parsed.data; + + // v1.15.20 — closed registration still admits a valid invite. The + // token itself is 32 random bytes behind a keyed hash and the surface + // is rate-limited (5 / 15 min / IP), so a distinct "invalid invite" + // message is a UX win, not an oracle. + if (!registrationEnabled && !inviteToken) { + return apiError("Registration is disabled", 403); + } // v1.4.25 W7 — accept a browser-detected timezone from the // registration form. Validate against the runtime IANA list; on @@ -109,6 +123,23 @@ export const POST = apiHandler(async (request: NextRequest) => { } const passwordHash = await hashPassword(password); + // v1.15.20 — consume the invite LAST, after every other validation, + // so a taken username or weak password can never burn one of the + // invite's uses. The guarded increment inside `consumeInviteToken` + // makes the last-use race safe under concurrent signups. + let inviteId: string | null = null; + if (!registrationEnabled && inviteToken) { + const consumed = await consumeInviteToken(inviteToken); + if (!consumed.ok) { + annotate({ + action: { name: "auth.register.invite_rejected" }, + meta: { reason: consumed.reason }, + }); + return apiError("Invalid or expired invite", 403); + } + inviteId = consumed.inviteId; + } + // First user becomes admin const role = userCount === 0 ? "ADMIN" : "USER"; @@ -130,10 +161,16 @@ export const POST = apiHandler(async (request: NextRequest) => { const ua = request.headers.get("user-agent"); await createSession(user.id, true, ip, ua); + // Stamp the consumer onto the invite (informational, best-effort — + // the use itself was already counted atomically above). + if (inviteId) { + await recordInviteConsumer(inviteId, user.id); + } + await auditLog("auth.register", { userId: user.id, ipAddress: ip, - details: { method: "password", timezone }, + details: { method: "password", timezone, invited: inviteId !== null }, }); annotate({ action: { name: "auth.register" } }); diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts new file mode 100644 index 000000000..8de3f1ff1 --- /dev/null +++ b/src/app/api/coach/about-me/route.ts @@ -0,0 +1,144 @@ +/** + * GET /api/coach/about-me — the caller's "about me" self-description. + * PUT /api/coach/about-me — write (or clear) the self-description. + * + * v1.15.20 — the user-authored context the Coach system prompt and the + * daily briefing inject as a delimited, user-provided block (see + * `src/lib/ai/coach/about-me.ts`). Free text, hard-capped at 4 000 chars, + * encrypted at rest through the shared Bytes codec. An empty / whitespace + * PUT clears the text (the row is kept with a NULL payload so `updatedAt` + * documents the deletion instant). + * + * Plain text end to end: the client renders the value as a React text + * child only — no markdown library exists in the tree and none may be + * added (XSS posture, see the contributor notes). + * + * Ownership: the user id always comes from `requireAuth()`; the body + * carries only the text. Audit rows never contain the text itself — only + * its length — because it is free-form health prose. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + decryptFromBytes, + encryptToBytes, +} from "@/lib/ai/coach/bytes-codec"; +import { + ABOUT_ME_MAX_CHARS, + aboutMePutSchema, +} from "@/lib/validations/about-me"; + +const PUT_RATE_LIMIT = 30; +const PUT_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const row = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { aboutMeEncrypted: true, updatedAt: true }, + }); + + let aboutMe: string | null = null; + if (row?.aboutMeEncrypted) { + try { + aboutMe = decryptFromBytes(row.aboutMeEncrypted); + } catch { + // Fail closed per row — never surface ciphertext. The user sees an + // empty editor and can re-write; the stale ciphertext is replaced + // on the next save. + aboutMe = null; + } + } + + annotate({ + action: { name: "coach.about_me.get" }, + meta: { present: aboutMe !== null }, + }); + + return apiSuccess({ + aboutMe, + updatedAt: row?.updatedAt?.toISOString() ?? null, + maxChars: ABOUT_ME_MAX_CHARS, + }); +}); + +export const PUT = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `coach-about-me:put:${user.id}`, + PUT_RATE_LIMIT, + PUT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = aboutMePutSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const text = parsed.data.aboutMe.trim(); + const cleared = text.length === 0; + const payload = cleared ? null : encryptToBytes(text); + + const row = await prisma.userHealthProfile.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + aboutMeEncrypted: payload, + }, + update: { + aboutMeEncrypted: payload, + }, + select: { updatedAt: true }, + }); + + // The audit row carries the length only — the text is free-form health + // prose and must not land in the (plaintext) audit details. + await auditLog( + cleared ? "coach.about_me.cleared" : "coach.about_me.updated", + { + userId: user.id, + ipAddress: getClientIp(req), + details: { length: text.length }, + }, + ); + + annotate({ + action: { + name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated", + }, + meta: { length: text.length }, + }); + + return apiSuccess({ + aboutMe: cleared ? null : text, + updatedAt: row.updatedAt.toISOString(), + maxChars: ABOUT_ME_MAX_CHARS, + }); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 90acb8440..05ec7a680 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -64,6 +64,7 @@ import { } from "@/lib/ai/coach/budget"; import { detectRefusal } from "@/lib/ai/coach/refusal"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { getAboutMeForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; @@ -313,7 +314,12 @@ async function handleChatRequest(request: NextRequest): Promise { ? { ...(scope ?? {}), window: coachPrefs.defaultWindow } : scope; const snapshot = await buildCoachSnapshot(userId, effectiveScope); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs); + // v1.15.20 — the user-authored "about me" self-description (Settings → + // AI) rides the system prompt as a delimited, user-provided context + // block. Fail-open: a missing / undecryptable text yields null and the + // prompt is byte-identical to the pre-feature one. + const aboutMe = await getAboutMeForUser(userId); + const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 5b59b3ad3..03966341e 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -26,6 +26,10 @@ import { type ComparisonSnapshot, } from "@/lib/ai/prompts/insight-system-prompt"; import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { + buildAboutMeInsightBlock, + getAboutMeForUser, +} from "@/lib/ai/coach/about-me"; import { metricsFromPresentSections } from "@/lib/ai/medical-references"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { @@ -544,6 +548,13 @@ export const POST = apiHandler(async (request: NextRequest) => { if (plateauContext) { userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); } + // v1.15.20 — fold the user-authored "about me" self-description + // (Settings → AI) into the briefing as a delimited, user-provided + // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. + const aboutMe = await getAboutMeForUser(userId); + if (aboutMe) { + userPrompt += buildAboutMeInsightBlock(aboutMe, locale); + } // v1.10.0 — fold a notable derived wellness signal (readiness / recovery // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. // Reads the one derived contract; null (the common case) costs nothing. diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 73e56ac8c..26150ab1f 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -1,9 +1,9 @@ "use client"; import { useId, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, UserPlus } from "lucide-react"; +import { Loader2, MailOpen, UserPlus } from "lucide-react"; import { Logo } from "@/components/ui/logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -16,6 +16,11 @@ import { queryKeys } from "@/lib/query-keys"; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); + // v1.15.20 — invite deep link (`/auth/register?invite=hlv_…`). The + // token rides the signup POST so a closed-registration instance still + // admits the invited user; an open instance simply ignores it. + const inviteToken = searchParams.get("invite"); const queryClient = useQueryClient(); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); @@ -45,7 +50,13 @@ export default function RegisterPage() { const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, username, password, timezone }), + body: JSON.stringify({ + email, + username, + password, + timezone, + ...(inviteToken ? { inviteToken } : {}), + }), }); const json = await res.json(); @@ -85,6 +96,17 @@ export default function RegisterPage() {
    + {inviteToken && ( +
    +
    + )} +
    diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx new file mode 100644 index 000000000..88bf5ee8e --- /dev/null +++ b/src/components/admin/invite-tokens-section.tsx @@ -0,0 +1,309 @@ +"use client"; + +/** + * v1.15.20 — admin card for registration invites (Users section). + * + * Mint an invite (7 / 14 / 30-day lifetime), hand it over as a link or + * QR code, see the open invites, revoke one. The raw token + URL + QR + * appear exactly once — straight from the POST response — and are gone + * after a reload; the list endpoint only ever returns metadata. + * + * The QR data-URL is rendered inside the mutation (not in an effect): + * `qrcode`'s `toDataURL` is async, so folding it into `mutationFn` + * keeps the component free of setState-in-effect churn. + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Copy, Loader2, Ticket, Trash2 } from "lucide-react"; +import QRCode from "qrcode"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +interface AdminInvite { + id: string; + createdAt: string; + expiresAt: string; + usedAt: string | null; + uses: number; + maxUses: number; + creator: { id: string; username: string } | null; + consumer: { id: string; username: string } | null; +} + +interface MintedInvite { + id: string; + token: string; + url: string; + expiresAt: string; + qrDataUrl: string; +} + +type InviteStatus = "active" | "expired" | "exhausted"; + +function deriveStatus(invite: AdminInvite, now: Date): InviteStatus { + if (invite.uses >= invite.maxUses) return "exhausted"; + if (new Date(invite.expiresAt).getTime() <= now.getTime()) return "expired"; + return "active"; +} + +const TTL_CHOICES = [7, 14, 30] as const; + +export function InviteTokensSection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [minted, setMinted] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: invites, isError } = useQuery({ + queryKey: queryKeys.adminInvites(), + queryFn: async () => { + const res = await fetch("/api/admin/invites"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as AdminInvite[]; + }, + }); + + const create = useMutation({ + mutationKey: queryKeys.adminInvites(), + mutationFn: async (expiresInDays: number): Promise => { + const res = await fetch("/api/admin/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expiresInDays }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()).data as Omit; + const qrDataUrl = await QRCode.toDataURL(data.url, { + width: 240, + margin: 1, + }); + return { ...data, qrDataUrl }; + }, + onSuccess: (invite) => { + setMinted(invite); + toast.success(t("admin.invites.createdToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() }); + }, + onError: () => { + toast.error(t("admin.invites.createError")); + }, + }); + + const remove = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/admin/invites/${id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }, + onSuccess: (_data, id) => { + toast.success(t("admin.invites.deletedToast")); + if (minted?.id === id) setMinted(null); + queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() }); + }, + onError: () => { + toast.error(t("admin.invites.deleteError")); + }, + }); + + async function copyUrl(url: string) { + try { + await navigator.clipboard.writeText(url); + toast.success(t("admin.invites.copiedToast")); + } catch { + toast.error(t("admin.invites.copyError")); + } + } + + const now = new Date(); + + return ( +
    +
    +
    +

    + {t("admin.invites.description")} +

    + +
    + + {t("admin.invites.createLabel")} + + {TTL_CHOICES.map((days) => ( + + ))} +
    + + {minted && ( +
    +

    {t("admin.invites.tokenTitle")}

    +

    + {t("admin.invites.tokenOnceHint")} +

    +

    + {minted.url} +

    +
    + {/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */} + {t("admin.invites.qrAlt")} + +
    +
    + )} + +
    + {isError && ( +

    + {t("admin.invites.loadError")} +

    + )} + {invites && invites.length === 0 && ( +

    + {t("admin.invites.listEmpty")} +

    + )} + {invites?.map((invite) => { + const status = deriveStatus(invite, now); + // Literal keys so the i18n call-site coverage guard sees them. + const statusLabel = + status === "active" + ? t("admin.invites.statusActive") + : status === "expired" + ? t("admin.invites.statusExpired") + : t("admin.invites.statusExhausted"); + return ( +
    +
    +
    + + {statusLabel} + + + {t("admin.invites.expires", { + date: formatDate(invite.expiresAt), + })} + + + {t("admin.invites.uses", { + used: invite.uses, + max: invite.maxUses, + })} + +
    +

    + {t("admin.invites.createdMeta", { + date: formatDate(invite.createdAt), + by: invite.creator?.username ?? "—", + })} + {invite.consumer + ? ` · ${t("admin.invites.usedBy", { + username: invite.consumer.username, + })}` + : ""} +

    +
    + +
    + ); + })} +
    + + { + if (!open) setDeleteTarget(null); + }} + > + + + + {t("admin.invites.deleteConfirmTitle")} + + + {t("admin.invites.deleteConfirmBody")} + + + + {t("admin.invites.cancel")} + { + if (deleteTarget) remove.mutate(deleteTarget.id); + setDeleteTarget(null); + }} + > + {t("admin.invites.deleteConfirmAction")} + + + + +
    + ); +} diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx new file mode 100644 index 000000000..83505500b --- /dev/null +++ b/src/components/settings/about-me-section.tsx @@ -0,0 +1,170 @@ +"use client"; + +/** + * v1.15.20 — Settings → AI "About me" panel. + * + * Free-text self-description the user writes for the AI surfaces. The + * server stores it encrypted (`UserHealthProfile.aboutMeEncrypted`) and + * injects it into the Coach system prompt + the daily briefing as a + * delimited, user-provided context block. + * + * GET /api/coach/about-me — { aboutMe, updatedAt, maxChars } + * PUT /api/coach/about-me — { aboutMe } (empty string clears) + * + * Plain text only — the value is rendered exclusively through a + * `