mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
This commit is contained in:
commit
9c8972bc96
86 files changed
+5051
-487
No files matched your search
+16
-16
@@ -14,9 +14,10 @@ updates:
|
||||
# single bumps: patch and minor updates travel together and pass the
|
||||
# same checks once. Majors stay single so each gets its own review.
|
||||
#
|
||||
# The Next.js line travels alone: while a 16.4 patch is still being
|
||||
# re-measured, a red Next bump inside the group holds every harmless
|
||||
# update in it red as well.
|
||||
# The Next.js line travels alone. Its bumps are the ones that move
|
||||
# URL state after history traversal (see the note on 16.4 below), and a
|
||||
# red Next bump inside the group would hold every harmless update in
|
||||
# it red as well.
|
||||
production-weekly:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
@@ -32,19 +33,18 @@ updates:
|
||||
exclude-patterns:
|
||||
- "eslint-config-next"
|
||||
- "@next/bundle-analyzer"
|
||||
ignore:
|
||||
# Next 16.4.0 breaks URL state after history traversal: with a sheet
|
||||
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
|
||||
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
|
||||
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
|
||||
# equal). 16.3.8 carries every published security fix, so the line
|
||||
# waits for a 16.4 patch; the two packages that move with it wait too.
|
||||
- dependency-name: "next"
|
||||
versions: ["16.4.0"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
versions: ["16.4.0"]
|
||||
- dependency-name: "@next/bundle-analyzer"
|
||||
versions: ["16.4.0"]
|
||||
# No ignore for the Next.js line any more. 16.4.0 lost URL state after
|
||||
# history traversal: with a sheet opened through `?doc=`, Back left it
|
||||
# open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
|
||||
# runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
|
||||
# same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
|
||||
# journey failed once in CI). Both were ours to fix: the sheet followed
|
||||
# `useSearchParams`, which 16.4 updates after the write it copies, and a
|
||||
# day step wrote the URL before Next had adopted the previous step, whose
|
||||
# late adoption then put the older URL back. The sheet now follows the
|
||||
# address bar and a step waits for the layer. Measured after both fixes:
|
||||
# 200 of 200 repeated runs of the three journeys, and another 80 of 80
|
||||
# each for the `?doc=` and day-key journeys.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
+117
-18
@@ -23,9 +23,16 @@ jobs:
|
||||
# Two shards grew to 14–19 minutes against the 20-minute cap once the
|
||||
# mobile route sweep landed, and a slow runner tipped them over; three
|
||||
# shards with a 30-minute cap leave room for both.
|
||||
# Every Playwright job runs inside Microsoft's Playwright image, which ships
|
||||
# the browsers with their system libraries: installing them from apt at run
|
||||
# time stalled shards for 29 minutes twice, once for WebKit and once for
|
||||
# Chromium alone.
|
||||
shard:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -38,8 +45,6 @@ jobs:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
@@ -47,7 +52,8 @@ jobs:
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
# Inside a job container the service is reached by its name.
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
@@ -56,10 +62,16 @@ jobs:
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
# The WebKit spec runs in the `webkit` job below.
|
||||
E2E_SKIP_WEBKIT: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The image runs as root while the checkout belongs to the runner user;
|
||||
# git refuses such a tree, and the prepare hook calls git.
|
||||
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
@@ -73,16 +85,6 @@ jobs:
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# `pnpm build` (not bare `next build`): the prebuild hook generates
|
||||
# the versioned service-worker stamp AND the static i18n catalog
|
||||
# assets (public/i18n/<locale>.js) the client boots from — without
|
||||
@@ -125,19 +127,116 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# The Playwright image must carry the browsers of the exact Playwright the
|
||||
# lockfile resolves, or the run refuses them. Read the version from the
|
||||
# lockfile so a dependency bump moves the image with it.
|
||||
playwright-version:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.read.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: pnpm-lock.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: read
|
||||
run: |
|
||||
version=$(grep -m1 -E "^ playwright-core@[0-9]" pnpm-lock.yaml | sed -E "s/^ playwright-core@([0-9.]+):.*/\1/")
|
||||
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
|
||||
# same image as the shards.
|
||||
webkit:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
timeout-minutes: 30
|
||||
name: e2e webkit
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
# Inside a job container the service is reached by its name.
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The image runs as root while the checkout belongs to the runner user;
|
||||
# git refuses such a tree, and the prepare hook calls git.
|
||||
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
|
||||
|
||||
- run: pnpm exec playwright test --project=webkit-iphone
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: playwright-report-webkit
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Playwright JSON report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: playwright-json-shard-webkit
|
||||
path: playwright-json/results.json
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# Branch protection requires a check named exactly "e2e". This job carries
|
||||
# that name and answers for both shards: it fails when any shard failed or
|
||||
# was cancelled, so a half-green matrix cannot satisfy the gate.
|
||||
# that name and answers for every shard and the WebKit job: it fails when
|
||||
# any of them failed or was cancelled, so a half-green run cannot satisfy
|
||||
# the gate.
|
||||
e2e:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: shard
|
||||
needs: [shard, webkit]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Every shard finished green
|
||||
run: |
|
||||
if [ "${{ needs.shard.result }}" != "success" ]; then
|
||||
echo "shard result: ${{ needs.shard.result }}"
|
||||
if [ "${{ needs.shard.result }}" != "success" ] || [ "${{ needs.webkit.result }}" != "success" ]; then
|
||||
echo "shard result: ${{ needs.shard.result }}, webkit result: ${{ needs.webkit.result }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
## [1.44.2] — 2026-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sheets that come up from the bottom can be pulled.** On a phone every
|
||||
sheet now follows your finger: pull it down to close it, flick it away,
|
||||
or let go early and it springs back. Content that is scrolled scrolls
|
||||
first, sideways gestures such as sliders keep working, and the X, Escape
|
||||
and a tap outside still close it. The day view opens at half height as
|
||||
before and pulls up to almost the full screen. The page behind no longer
|
||||
reloads when you pull inside a sheet, and closing a sheet puts the focus
|
||||
back where you were.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your own targets shape the dashboard charts.** Blood pressure, resting
|
||||
pulse and body fat drew the standard band on the dashboard even when you
|
||||
had set your own targets; they now draw yours, and the tile colours follow
|
||||
them. The oxygen saturation page uses your target too. Without your own
|
||||
targets nothing changes. (#1261)
|
||||
- **Local thinking models finish the briefing, and regenerate waits for
|
||||
them.** A thinking model such as Gemma 4 on Ollama could spend a long
|
||||
briefing thinking and hand back no answer, which was logged as a
|
||||
connection problem and shown as a timeout. A reply with nothing but
|
||||
thinking is now asked once more without thinking, and that model is asked
|
||||
that way for the next hour, through the Local provider and an
|
||||
OpenAI-compatible gateway alike. The regenerate button waits as long as
|
||||
your AI response timeout allows instead of giving up after 45 seconds, and
|
||||
so does saving your notes about yourself; a local model allowed more than
|
||||
five minutes is no longer cut off at five. A failed call is logged with
|
||||
what actually went wrong, such as a refused or dropped connection, an
|
||||
empty reply or a timeout, and the briefing only suggests a longer timeout
|
||||
when time really ran out. The provider's reasoning setting now says that
|
||||
the daily briefing thinks at its own depth. (#1267)
|
||||
|
||||
### Thanks
|
||||
|
||||
Thanks to @mills1975 for spotting that the targets never reached the chart
|
||||
(#1261).
|
||||
|
||||
Thanks to @Tio61 for the precise report that traced the regenerate timeout
|
||||
to its line and ruled out the transport with their own requests from inside
|
||||
the container (#1267).
|
||||
|
||||
## [1.44.1] — 2026-10-22
|
||||
|
||||
### Security
|
||||
|
||||
- **Next.js 16.4.2.** The framework fixed two critical advisories that
|
||||
allow unauthenticated remote code execution, one in `use cache` functions
|
||||
that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image
|
||||
optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity
|
||||
denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog
|
||||
uses neither `use cache` nor the image component, and it defines no Server
|
||||
Actions and refuses requests that call one. Every instance runs the same
|
||||
framework, though, so it updates anyway.
|
||||
- **The image optimizer is off.** Nothing in HealthLog renders through it,
|
||||
so `/_next/image` now answers 404 instead of processing whatever image a
|
||||
request names. Icons and logos load as before.
|
||||
- **nodemailer 10.1.7** fixes several advisories in how email addresses are
|
||||
parsed (GHSA-8c8m-p3m8-cjg8 among them).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`[` and `]` move the open day on every press.** Pressed quickly in a
|
||||
row, one press could be lost and the day ended one short. A step now
|
||||
starts from the day in the address bar and waits until the previous one
|
||||
is shown.
|
||||
- **Back closes a document opened from the vault.** Pressed right after
|
||||
opening a document, Back could leave its sheet open. The sheet now follows
|
||||
the address bar directly.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- None. Pull the new image and restart.
|
||||
|
||||
## [1.44.0] — 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
+12
-8
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.44.0
|
||||
version: 1.44.2
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -64112,7 +64112,8 @@ components:
|
||||
- diaHigh
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Personalised blood-pressure target numbers. Null when the profile has no date of birth.
|
||||
description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the
|
||||
age-banded default. Null when there is neither a target nor a date of birth."
|
||||
bpSysRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
@@ -64132,7 +64133,8 @@ components:
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Systolic traffic-light range. Null when the profile has no date of birth.
|
||||
description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is
|
||||
neither a target nor a date of birth.
|
||||
bpDiaRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
@@ -64152,7 +64154,8 @@ components:
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Diastolic traffic-light range. Null when the profile has no date of birth.
|
||||
description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is
|
||||
neither a target nor a date of birth.
|
||||
pulseDisplayRange:
|
||||
type: object
|
||||
properties:
|
||||
@@ -64170,7 +64173,8 @@ components:
|
||||
- orangeMin
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.
|
||||
description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA
|
||||
reference range with no date of birth). Always present."
|
||||
pulseBands:
|
||||
type: array
|
||||
items:
|
||||
@@ -64191,7 +64195,7 @@ components:
|
||||
- max
|
||||
- color
|
||||
additionalProperties: false
|
||||
description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth.
|
||||
description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.
|
||||
bodyFatRange:
|
||||
type: object
|
||||
properties:
|
||||
@@ -64203,7 +64207,7 @@ components:
|
||||
- min
|
||||
- max
|
||||
additionalProperties: false
|
||||
description: Gender-aware body-fat target range. Always present.
|
||||
description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present."
|
||||
bodyFatBands:
|
||||
type: array
|
||||
items:
|
||||
@@ -64224,7 +64228,7 @@ components:
|
||||
- max
|
||||
- color
|
||||
additionalProperties: false
|
||||
description: Body-fat chart bands. Always present.
|
||||
description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.
|
||||
weightRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
|
||||
@@ -98,6 +98,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
|
||||
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
|
||||
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
|
||||
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
|
||||
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
|
||||
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
|
||||
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The bottom sheet's drag on WebKit with an iPhone profile (the
|
||||
* `webkit-iphone` project), against the three kinds of bottom sheet the app
|
||||
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
|
||||
* preview step) and a capture form (`ResponsiveSheet` on a phone).
|
||||
*
|
||||
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
|
||||
* through the page as the browser would deliver it: `pointerdown` /
|
||||
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
|
||||
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
|
||||
* paced in real time so the release speed is real. Nothing calls into the
|
||||
* sheet's own code. What synthetic events cannot do is scroll natively, so
|
||||
* "content scrolls first" is proven as "a scrolled body does not move the
|
||||
* sheet", with the scroll position set the way a scroll would leave it.
|
||||
*
|
||||
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
|
||||
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
|
||||
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
|
||||
*/
|
||||
|
||||
interface Drag {
|
||||
dx?: number;
|
||||
dy: number;
|
||||
/** Duration of the movement, ms. */
|
||||
ms: number;
|
||||
steps?: number;
|
||||
/** Rest before lifting the finger, ms (a slow, deliberate release). */
|
||||
holdMs?: number;
|
||||
/**
|
||||
* After the rest, keep the finger down until the page has rendered this
|
||||
* many frames. The sheet writes its position once per animation frame, and
|
||||
* WebKit in the CI container can go a second or more without rendering a
|
||||
* frame while timers and events run on (the trace shows the More sheet
|
||||
* still sliding in 1.5 s after its animation reported finished). A check
|
||||
* on where the sheet was drawn needs those frames; a check on where it
|
||||
* ends up does not, because the release is decided from the touch alone.
|
||||
*/
|
||||
holdFrames?: number;
|
||||
}
|
||||
|
||||
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
|
||||
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
|
||||
await page.evaluate(
|
||||
async ({ x, y, dx, dy, ms, steps, holdMs, holdFrames }) => {
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) throw new Error(`nothing under ${x},${y}`);
|
||||
const id = 11;
|
||||
const touchAt = (cx: number, cy: number) => {
|
||||
const init = {
|
||||
identifier: id,
|
||||
target,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
pageX: cx,
|
||||
pageY: cy,
|
||||
screenX: cx,
|
||||
screenY: cy,
|
||||
radiusX: 4,
|
||||
radiusY: 4,
|
||||
force: 1,
|
||||
};
|
||||
try {
|
||||
return new Touch(init);
|
||||
} catch {
|
||||
return init as unknown as Touch;
|
||||
}
|
||||
};
|
||||
const touchEvent = (type: string, cx: number, cy: number) => {
|
||||
const touch = touchAt(cx, cy);
|
||||
const touches = type === "touchend" ? [] : [touch];
|
||||
try {
|
||||
return new TouchEvent(type, {
|
||||
touches,
|
||||
targetTouches: touches,
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
} catch {
|
||||
const event = new Event(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperty(event, "touches", { value: touches });
|
||||
Object.defineProperty(event, "targetTouches", { value: touches });
|
||||
Object.defineProperty(event, "changedTouches", { value: [touch] });
|
||||
return event;
|
||||
}
|
||||
};
|
||||
const fire = (
|
||||
pointer: "pointerdown" | "pointermove" | "pointerup",
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(pointer, {
|
||||
pointerId: id,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
buttons: pointer === "pointerup" ? 0 : 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
const touch = {
|
||||
pointerdown: "touchstart",
|
||||
pointermove: "touchmove",
|
||||
pointerup: "touchend",
|
||||
}[pointer];
|
||||
target.dispatchEvent(touchEvent(touch, cx, cy));
|
||||
};
|
||||
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
|
||||
fire("pointerdown", x, y);
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
await wait(ms / steps);
|
||||
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
|
||||
}
|
||||
if (holdMs) await wait(holdMs);
|
||||
for (let i = 0; i < holdFrames; i += 1) {
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
}
|
||||
fire("pointerup", x + dx, y + dy);
|
||||
},
|
||||
{
|
||||
x,
|
||||
y,
|
||||
dx: drag.dx ?? 0,
|
||||
dy: drag.dy,
|
||||
ms: drag.ms,
|
||||
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
|
||||
holdMs: drag.holdMs ?? 0,
|
||||
holdFrames: drag.holdFrames ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's pointer capture for a dispatched pointer. A real touch is
|
||||
* captured by the browser; a dispatched one is not, and Radix's slider only
|
||||
* follows a pointer it holds. This gives it the same bookkeeping, nothing
|
||||
* more: events still go to the element they are dispatched on.
|
||||
*/
|
||||
async function emulateCapture(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const held = new WeakMap<Element, Set<number>>();
|
||||
Element.prototype.setPointerCapture = function (id: number) {
|
||||
const set = held.get(this) ?? new Set<number>();
|
||||
set.add(id);
|
||||
held.set(this, set);
|
||||
};
|
||||
Element.prototype.hasPointerCapture = function (id: number) {
|
||||
return held.get(this)?.has(id) ?? false;
|
||||
};
|
||||
Element.prototype.releasePointerCapture = function (id: number) {
|
||||
held.get(this)?.delete(id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function centre(locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("not on screen");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
|
||||
}
|
||||
|
||||
async function topOf(locator: Locator) {
|
||||
return (await locator.boundingBox())?.y ?? NaN;
|
||||
}
|
||||
|
||||
/** Waits for the sheet to come to rest (open slide or settle finished). */
|
||||
async function settled(locator: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
locator.evaluate((el) =>
|
||||
el.getAnimations().every((a) => a.playState !== "running"),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
// A finished animation is not a drawn one: WebKit can report the slide
|
||||
// done while it has not rendered a frame of it yet. Two frames, then rest.
|
||||
await locator
|
||||
.page()
|
||||
.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await locator.page().waitForTimeout(300);
|
||||
}
|
||||
|
||||
const more = (page: Page) =>
|
||||
page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
|
||||
async function openMore(page: Page) {
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
await expect(more(page)).toBeVisible();
|
||||
await settled(more(page));
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test.describe("the bottom sheet", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page);
|
||||
});
|
||||
|
||||
test("a slow drag past a quarter of its height closes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const title = more(page).getByRole("heading").first();
|
||||
const { x, y } = await centre(title);
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, {
|
||||
dy: box.height * 0.4,
|
||||
ms: 700,
|
||||
holdMs: 160,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a short drag springs back", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
await expect(more(page)).toBeVisible();
|
||||
const before = await topOf(more(page));
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
// Dragging: the sheet follows the finger and the backdrop lightens. A
|
||||
// frame loop records the furthest the sheet travelled and the lightest
|
||||
// the backdrop got for the whole gesture, so a slow runner cannot make
|
||||
// a single fixed-time sample land before the drag has begun.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__sheetDrag?: { moved: number; opacity: number; on: boolean };
|
||||
};
|
||||
w.__sheetDrag = { moved: 0, opacity: 1, on: true };
|
||||
const tick = () => {
|
||||
const rec = w.__sheetDrag!;
|
||||
if (!rec.on) return;
|
||||
const sheet = document.querySelector(
|
||||
'[data-testid="bottom-nav-more-sheet"]',
|
||||
) as HTMLElement | null;
|
||||
const overlay = document.querySelector(
|
||||
'[data-slot="sheet-overlay"]',
|
||||
) as HTMLElement | null;
|
||||
if (sheet) {
|
||||
const moved = new DOMMatrix(getComputedStyle(sheet).transform).f;
|
||||
rec.moved = Math.max(rec.moved, moved);
|
||||
}
|
||||
if (overlay) {
|
||||
rec.opacity = Math.min(
|
||||
rec.opacity,
|
||||
Number(getComputedStyle(overlay).opacity),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
// Two rendered frames with the finger still down: the first paints the
|
||||
// sheet where the finger is, the second lets the recorder read it.
|
||||
await touchDrag(page, x, y, {
|
||||
dy: 60,
|
||||
ms: 300,
|
||||
holdMs: 200,
|
||||
holdFrames: 2,
|
||||
});
|
||||
const seen = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__sheetDrag: { moved: number; opacity: number; on: boolean };
|
||||
};
|
||||
w.__sheetDrag.on = false;
|
||||
return { moved: w.__sheetDrag.moved, opacity: w.__sheetDrag.opacity };
|
||||
});
|
||||
expect(seen.moved).toBeGreaterThan(40);
|
||||
expect(seen.opacity).toBeLessThan(1);
|
||||
await settled(more(page));
|
||||
await expect(more(page)).toBeVisible();
|
||||
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("a fast flick closes it", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
const { box } = await centre(more(page));
|
||||
// Well short of a quarter of the height, but fast.
|
||||
await touchDrag(page, x, y, {
|
||||
dy: Math.min(70, box.height * 0.2),
|
||||
ms: 40,
|
||||
steps: 4,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
const trigger = page.locator('[data-testid="bottom-nav-more"]');
|
||||
|
||||
await openMore(page);
|
||||
await page.touchscreen.tap(195, 40);
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
await openMore(page);
|
||||
const close = more(page).locator('[data-slot="sheet-close-button"]');
|
||||
const closeBox = (await close.boundingBox())!;
|
||||
expect(closeBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(closeBox.height).toBeGreaterThanOrEqual(44);
|
||||
await close.tap();
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
// The keyboard path: open from the focused trigger, close with Escape.
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(more(page)).toBeVisible();
|
||||
await expect(more(page)).toHaveAttribute("role", "dialog");
|
||||
await expect(more(page)).toHaveAttribute("aria-modal", "true");
|
||||
await expect(more(page)).toHaveAccessibleName(/more/i);
|
||||
await expect
|
||||
.poll(() =>
|
||||
more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
)
|
||||
.toBe(true);
|
||||
// Tab stays inside.
|
||||
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
|
||||
expect(
|
||||
await more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const main = page.locator("#main-content");
|
||||
await expect(main).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Watch for the refresh disc for the rest of the test.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
const ptrSeen = () =>
|
||||
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
|
||||
|
||||
// The control: the same pull on the page itself does arm the disc, so
|
||||
// the watch above can see it (short of the refresh threshold).
|
||||
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
|
||||
await expect.poll(ptrSeen).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
});
|
||||
|
||||
// A pull on the sheet, short (it springs back) and long (it closes),
|
||||
// from the dashboard at the top: the page behind never takes it.
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
|
||||
await settled(more(page));
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
|
||||
// Scrolled down, the page is where it was after the sheet has gone.
|
||||
await main.evaluate((el) => el.scrollTo({ top: 400 }));
|
||||
const before = await main.evaluate((el) => el.scrollTop);
|
||||
expect(before).toBeGreaterThan(0);
|
||||
await openMore(page);
|
||||
const at = await centre(more(page).getByRole("heading").first());
|
||||
const sheetBox = (await more(page).boundingBox())!;
|
||||
await touchDrag(page, at.x, at.y, {
|
||||
dy: sheetBox.height * 0.5,
|
||||
ms: 600,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
});
|
||||
|
||||
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A short window, so the day's content is longer than its sheet on
|
||||
// both steps and the body really scrolls.
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveAttribute("data-shell", "bottom");
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const viewport = page.viewportSize()!;
|
||||
|
||||
// On the preview step the footer stands on the window's bottom edge.
|
||||
const footer = panel.locator('[data-slot="day-footer"]');
|
||||
const footerBox = (await footer.boundingBox())!;
|
||||
expect(
|
||||
Math.abs(footerBox.y + footerBox.height - viewport.height),
|
||||
).toBeLessThan(2);
|
||||
|
||||
// A pull up on the content raises it to the full 90 dvh.
|
||||
const body = panel.locator('[data-slot="day-body"]');
|
||||
const b = await centre(body);
|
||||
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await settled(panel);
|
||||
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
|
||||
|
||||
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
|
||||
const scrollable = await body.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
expect(scrollable).toBe(true);
|
||||
await body.evaluate((el) => el.scrollTo({ top: 60 }));
|
||||
const top = await topOf(panel);
|
||||
const inner = await centre(body);
|
||||
await touchDrag(page, inner.x, inner.y, {
|
||||
dy: 260,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await settled(panel);
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
|
||||
|
||||
// At its very top the same pull drags the sheet: down to the preview
|
||||
// step, and from there out.
|
||||
await body.evaluate((el) => el.scrollTo({ top: 0 }));
|
||||
await touchDrag(page, inner.x, inner.y - 60, {
|
||||
dy: 150,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
const g = await centre(grip);
|
||||
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("the grip is a button that switches the day's two heights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "false");
|
||||
await grip.tap();
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("a slider inside a sheet moves sideways and the sheet stays", async ({
|
||||
page,
|
||||
}) => {
|
||||
await emulateCapture(page);
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-testid="bottom-nav-capture"]').tap();
|
||||
await page.locator('[data-testid="capture-picker-symptom"]').tap();
|
||||
const form = page.locator(
|
||||
'[data-slot="responsive-sheet-content"][data-state="open"]',
|
||||
);
|
||||
await expect(form).toBeVisible();
|
||||
await settled(form);
|
||||
const slider = form.locator('[data-slot="slider"]').first();
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
await settled(form);
|
||||
const top = await topOf(form);
|
||||
const s = await centre(slider);
|
||||
const thumb = form.locator('[role="slider"]').first();
|
||||
const valueOf = async () =>
|
||||
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
|
||||
const before = await valueOf();
|
||||
// A sideways drag with a little vertical wobble, across the track.
|
||||
await touchDrag(page, s.box.x + 8, s.y, {
|
||||
dx: s.box.width * 0.7,
|
||||
dy: 6,
|
||||
ms: 400,
|
||||
});
|
||||
await expect.poll(valueOf).not.toBe(before);
|
||||
await settled(form);
|
||||
await expect(form).toBeVisible();
|
||||
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("under reduced motion it opens without a slide", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
await expect(more(page)).toBeVisible();
|
||||
expect(
|
||||
await more(page).evaluate(
|
||||
(el) =>
|
||||
el.getAnimations().filter((a) => a.playState === "running").length,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,12 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet: a point is a small target.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
// The button is a calm, full 44 px row.
|
||||
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
// The button is a calm, full 44 px row. The tooltip re-renders while the
|
||||
// tap settles, so the measurement retries instead of reading a box from
|
||||
// a node that has just been replaced.
|
||||
await expect
|
||||
.poll(async () => (await open.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
},
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen.",
|
||||
"backgroundNote": "Hintergrundarbeit wie das Tagesbriefing nutzt diese Einstellung nicht: Sie denkt höchstens auf Mittel nach, weniger, wenn der Betreiber eine niedrigere Grenze setzt, und gar nicht auf der Stufe Sparsam."
|
||||
},
|
||||
"providerCardDescription": "Richte Schlüssel und Modell je Anbieter ein; die Reihenfolge darunter bestimmt, welchen HealthLog zuerst fragt.",
|
||||
"runtimeTitle": "Prüfen und aktualisieren",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies.",
|
||||
"backgroundNote": "Background work such as the daily briefing does not use this setting: it thinks at Medium at most, lower when the operator sets a lower limit, and not at all on the Frugal usage level."
|
||||
},
|
||||
"providerCardDescription": "Set up each provider's key and model; the order below decides which one HealthLog asks first.",
|
||||
"runtimeTitle": "Check and refresh",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
|
||||
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach.",
|
||||
"backgroundNote": "El trabajo en segundo plano, como el resumen diario, no usa este ajuste: piensa como máximo en Medio, menos si el operador fija un límite más bajo, y nada en el nivel Ahorro."
|
||||
},
|
||||
"providerCardDescription": "Configura la clave y el modelo de cada proveedor; el orden de abajo decide a cuál pregunta HealthLog primero.",
|
||||
"runtimeTitle": "Comprobar y actualizar",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Moyen",
|
||||
"high": "Élevé"
|
||||
},
|
||||
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
|
||||
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique.",
|
||||
"backgroundNote": "Les tâches de fond comme le briefing quotidien n'utilisent pas ce réglage : elles réfléchissent au plus en Moyen, moins si l'opérateur fixe une limite plus basse, et pas du tout au niveau Économe."
|
||||
},
|
||||
"providerCardDescription": "Configurez la clé et le modèle de chaque fournisseur ; l’ordre ci-dessous décide lequel HealthLog sollicite en premier.",
|
||||
"runtimeTitle": "Vérifier et actualiser",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach.",
|
||||
"backgroundNote": "Il lavoro in background, come il briefing giornaliero, non usa questa impostazione: ragiona al massimo su Medio, meno se l'operatore imposta un limite più basso, e per niente al livello Risparmio."
|
||||
},
|
||||
"providerCardDescription": "Configura chiave e modello di ogni fornitore; l’ordine qui sotto decide quale HealthLog interpella per primo.",
|
||||
"runtimeTitle": "Verifica e aggiorna",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
},
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다.",
|
||||
"backgroundNote": "일일 브리핑 같은 백그라운드 작업은 이 설정을 쓰지 않습니다. 최대 보통 수준으로 생각하며, 운영자가 더 낮은 한도를 정하면 그보다 낮게, 절약 단계에서는 전혀 생각하지 않습니다."
|
||||
},
|
||||
"providerCardDescription": "제공자마다 키와 모델을 설정하고, 아래 순서로 HealthLog가 먼저 쓸 제공자를 정해요.",
|
||||
"runtimeTitle": "확인 및 새로고침",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Gemiddeld",
|
||||
"high": "Hoog"
|
||||
},
|
||||
"coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen."
|
||||
"coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen.",
|
||||
"backgroundNote": "Achtergrondwerk zoals de dagelijkse briefing gebruikt deze instelling niet: het denkt hoogstens op Gemiddeld na, minder als de beheerder een lagere grens instelt, en helemaal niet op het niveau Zuinig."
|
||||
},
|
||||
"providerCardDescription": "Stel per provider de sleutel en het model in; de volgorde hieronder bepaalt welke HealthLog als eerste raadpleegt.",
|
||||
"runtimeTitle": "Controleren en vernieuwen",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
},
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha.",
|
||||
"backgroundNote": "Praca w tle, taka jak codzienne podsumowanie, nie korzysta z tego ustawienia: myśli najwyżej na poziomie Średnie, niżej, gdy operator ustawi niższy limit, a na poziomie Oszczędny wcale."
|
||||
},
|
||||
"providerCardDescription": "Skonfiguruj klucz i model każdego dostawcy; kolejność poniżej decyduje, którego HealthLog pyta najpierw.",
|
||||
"runtimeTitle": "Sprawdź i odśwież",
|
||||
|
||||
+2
-1
@@ -6558,7 +6558,8 @@
|
||||
"medium": "Médio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"coachNote": "No Coach, aplica-se a profundidade de pensamento definida nas configurações do Coach."
|
||||
"coachNote": "No Coach, aplica-se a profundidade de pensamento definida nas configurações do Coach.",
|
||||
"backgroundNote": "O trabalho em segundo plano, como o resumo diário, não usa esta configuração: pensa no máximo em Médio, menos quando o operador define um limite menor, e nada no nível de uso Frugal."
|
||||
},
|
||||
"providerCardDescription": "Configure a chave e o modelo de cada provedor; a ordem abaixo decide qual deles o HealthLog consulta primeiro.",
|
||||
"runtimeTitle": "Verificar e atualizar",
|
||||
|
||||
@@ -35,6 +35,16 @@ const nextConfig: NextConfig = {
|
||||
// still covers the full program.
|
||||
typescript: { tsconfigPath: "tsconfig.build.json" },
|
||||
poweredByHeader: false,
|
||||
// The image optimizer is off. Nothing renders through `next/image`: logos,
|
||||
// icons and avatars are plain `<img>` elements served from `public/` or
|
||||
// from an authenticated route. With the optimizer on, `/_next/image` still
|
||||
// fetched, decoded and re-encoded whatever local path a request named,
|
||||
// which is surface this app has no use for and where the framework has
|
||||
// shipped critical advisories. Off, Next answers that route with 404
|
||||
// before it reads a single parameter. `next-image-optimizer-guard.test.ts`
|
||||
// holds this: it fails when `next/image` is imported while the optimizer
|
||||
// is off, and when the optimizer comes back with SVG input allowed.
|
||||
images: { unoptimized: true },
|
||||
// v1.4.38.4 — expose the package.json version to the client bundle
|
||||
// so the `<VersionPoller>` can compare the shell-baked version
|
||||
// against the live `/api/version` response and trigger a self-heal
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -79,7 +79,7 @@
|
||||
"jspdf-autotable": "^5.0.9",
|
||||
"lucide-react": "^1.59.0",
|
||||
"mmdb-lib": "^3.0.3",
|
||||
"next": "16.3.8",
|
||||
"next": "16.4.2",
|
||||
"nodemailer": "10.1.7",
|
||||
"otpauth": "^9.5.2",
|
||||
"p-limit": "^7.3.3",
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
"@next/bundle-analyzer": "^16.3.8",
|
||||
"@next/bundle-analyzer": "16.4.2",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@stryker-mutator/core": "10.0.0",
|
||||
"@stryker-mutator/vitest-runner": "10.0.0",
|
||||
@@ -123,7 +123,7 @@
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"eslint-config-next": "16.4.2",
|
||||
"fast-check": "4.10.2",
|
||||
"knip": "^6.41.0",
|
||||
"lefthook": "^2.2.2",
|
||||
|
||||
@@ -113,6 +113,8 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
@@ -228,6 +230,8 @@ export default defineConfig({
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// The Apple Health import journey uploads an archive into the one
|
||||
// shared account and its refusal control asserts that the account's
|
||||
// Apple-Health row count did not move. A second project running the
|
||||
@@ -304,6 +308,24 @@ export default defineConfig({
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
// The bottom sheet's drag, on the engine it is for: iOS Safari is
|
||||
// WebKit, and Chromium's touch scrolling and pointer capture behave
|
||||
// differently enough that a pass there proves little about an iPhone.
|
||||
// CI runs it in its own job inside the Playwright image, where WebKit's
|
||||
// system libraries are already present; the Chromium shards set
|
||||
// E2E_SKIP_WEBKIT so they neither install WebKit nor pick the spec up.
|
||||
...(process.env.E2E_SKIP_WEBKIT
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "webkit-iphone",
|
||||
testMatch: ["bottom-sheet.spec.ts"],
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
|
||||
Generated
+61
-93
@@ -124,8 +124,8 @@ importers:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
next:
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
specifier: 16.4.2
|
||||
version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
nodemailer:
|
||||
specifier: 10.1.7
|
||||
version: 10.1.7
|
||||
@@ -200,8 +200,8 @@ importers:
|
||||
specifier: ^4.14.0
|
||||
version: 4.14.0(playwright-core@1.64.1)
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.3.8
|
||||
version: 16.3.8
|
||||
specifier: 16.4.2
|
||||
version: 16.4.2
|
||||
'@playwright/test':
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
@@ -251,8 +251,8 @@ importers:
|
||||
specifier: ^9
|
||||
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
eslint-config-next:
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||
specifier: 16.4.2
|
||||
version: 16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||
fast-check:
|
||||
specifier: 4.10.2
|
||||
version: 4.10.2
|
||||
@@ -1376,13 +1376,6 @@ packages:
|
||||
resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.4':
|
||||
resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.5':
|
||||
resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
@@ -1390,63 +1383,63 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/bundle-analyzer@16.3.8':
|
||||
resolution: {integrity: sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==}
|
||||
'@next/bundle-analyzer@16.4.2':
|
||||
resolution: {integrity: sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==}
|
||||
|
||||
'@next/env@16.3.8':
|
||||
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||
'@next/env@16.4.2':
|
||||
resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
|
||||
|
||||
'@next/eslint-plugin-next@16.3.8':
|
||||
resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
|
||||
'@next/eslint-plugin-next@16.4.2':
|
||||
resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.8':
|
||||
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||
'@next/swc-darwin-arm64@16.4.2':
|
||||
resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.3.8':
|
||||
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||
'@next/swc-darwin-x64@16.4.2':
|
||||
resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.8':
|
||||
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||
'@next/swc-linux-arm64-gnu@16.4.2':
|
||||
resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.8':
|
||||
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||
'@next/swc-linux-arm64-musl@16.4.2':
|
||||
resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.8':
|
||||
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||
'@next/swc-linux-x64-gnu@16.4.2':
|
||||
resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.8':
|
||||
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||
'@next/swc-linux-x64-musl@16.4.2':
|
||||
resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.8':
|
||||
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||
'@next/swc-win32-arm64-msvc@16.4.2':
|
||||
resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.8':
|
||||
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||
'@next/swc-win32-x64-msvc@16.4.2':
|
||||
resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -3619,10 +3612,6 @@ packages:
|
||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
axe-core@4.13.0:
|
||||
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
axe-core@4.14.0:
|
||||
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3690,11 +3679,6 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.11.26:
|
||||
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
baseline-browser-mapping@2.11.27:
|
||||
resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -3801,9 +3785,6 @@ packages:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001814:
|
||||
resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
|
||||
|
||||
caniuse-lite@1.0.30001815:
|
||||
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
|
||||
|
||||
@@ -4316,8 +4297,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@16.3.8:
|
||||
resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
|
||||
eslint-config-next@16.4.2:
|
||||
resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -5639,11 +5620,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.20:
|
||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -5661,8 +5637,8 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
next@16.3.8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
next@16.4.2:
|
||||
resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -8518,7 +8494,7 @@ snapshots:
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.11
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.11
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
'@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
@@ -8539,44 +8515,44 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.4
|
||||
optional: true
|
||||
|
||||
'@next/bundle-analyzer@16.3.8':
|
||||
'@next/bundle-analyzer@16.4.2':
|
||||
dependencies:
|
||||
webpack-bundle-analyzer: 4.10.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@next/env@16.3.8': {}
|
||||
'@next/env@16.4.2': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
|
||||
'@next/eslint-plugin-next@16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
fast-glob: 3.3.1
|
||||
transitivePeerDependencies:
|
||||
- eslint
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.8':
|
||||
'@next/swc-darwin-arm64@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.3.8':
|
||||
'@next/swc-darwin-x64@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.8':
|
||||
'@next/swc-linux-arm64-gnu@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.8':
|
||||
'@next/swc-linux-arm64-musl@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.8':
|
||||
'@next/swc-linux-x64-gnu@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.8':
|
||||
'@next/swc-linux-x64-musl@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.8':
|
||||
'@next/swc-win32-arm64-msvc@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.8':
|
||||
'@next/swc-win32-x64-msvc@16.4.2':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@2.4.0': {}
|
||||
@@ -10491,7 +10467,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
|
||||
@@ -10815,8 +10791,6 @@ snapshots:
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
|
||||
axe-core@4.13.0: {}
|
||||
|
||||
axe-core@4.14.0: {}
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
@@ -10861,8 +10835,6 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.11.26: {}
|
||||
|
||||
baseline-browser-mapping@2.11.27: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
@@ -10990,8 +10962,6 @@ snapshots:
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001814: {}
|
||||
|
||||
caniuse-lite@1.0.30001815: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
@@ -11524,9 +11494,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
|
||||
eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
'@next/eslint-plugin-next': 16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
@@ -11613,7 +11583,7 @@ snapshots:
|
||||
array-includes: 3.2.0
|
||||
array.prototype.flatmap: 1.3.3
|
||||
ast-types-flow: 0.0.8
|
||||
axe-core: 4.13.0
|
||||
axe-core: 4.14.0
|
||||
axobject-query: 4.1.0
|
||||
damerau-levenshtein: 1.0.8
|
||||
emoji-regex: 9.2.2
|
||||
@@ -12858,8 +12828,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
nanoid@3.3.20: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
@@ -12868,25 +12836,25 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
dependencies:
|
||||
'@next/env': 16.3.8
|
||||
'@next/env': 16.4.2
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.26
|
||||
caniuse-lite: 1.0.30001814
|
||||
baseline-browser-mapping: 2.11.27
|
||||
caniuse-lite: 1.0.30001815
|
||||
postcss: 8.5.23
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.1)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.8
|
||||
'@next/swc-darwin-x64': 16.3.8
|
||||
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||
'@next/swc-linux-arm64-musl': 16.3.8
|
||||
'@next/swc-linux-x64-gnu': 16.3.8
|
||||
'@next/swc-linux-x64-musl': 16.3.8
|
||||
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||
'@next/swc-win32-x64-msvc': 16.3.8
|
||||
'@next/swc-darwin-arm64': 16.4.2
|
||||
'@next/swc-darwin-x64': 16.4.2
|
||||
'@next/swc-linux-arm64-gnu': 16.4.2
|
||||
'@next/swc-linux-arm64-musl': 16.4.2
|
||||
'@next/swc-linux-x64-gnu': 16.4.2
|
||||
'@next/swc-linux-x64-musl': 16.4.2
|
||||
'@next/swc-win32-arm64-msvc': 16.4.2
|
||||
'@next/swc-win32-x64-msvc': 16.4.2
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.64.1
|
||||
sharp: 0.35.5(@types/node@26.6.5)
|
||||
@@ -13251,7 +13219,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.44.0";
|
||||
/* @sw-version-fallback */ "v1.44.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Structural guard (#1267): a browser request that waits on a model call
|
||||
* takes its abort from the person's response-timeout setting, never from a
|
||||
* constant.
|
||||
*
|
||||
* The force regenerate aborted at a fixed 45 s while the server still had
|
||||
* minutes left on the person's 600 s setting, and a save of the about-me
|
||||
* self-context (which derives the clarifying questions inline) ran under the
|
||||
* 15 s request default. Both now read `clientAbortMs` through the account
|
||||
* payload's `ai.provider.responseTimeoutMs`; the inventory of every such
|
||||
* request is in the release notes for #1267.
|
||||
*
|
||||
* Its limit: it reads source text. A new synchronous AI route called from the
|
||||
* browser with a fixed `AbortSignal.timeout(...)` is not found by name here;
|
||||
* `use-insights-advisor-force.test.ts` proves the behaviour for the
|
||||
* regenerate with fake timers.
|
||||
*
|
||||
* Mutation check: put `FORCE_ADVISOR_TIMEOUT_MS = 45_000` back into the
|
||||
* advisor, or drop the `signal` from one about-me save, and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The text of the call that starts at `open` (the index of its `(`). */
|
||||
function callText(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i += 1) {
|
||||
if (source[i] === "(") depth += 1;
|
||||
else if (source[i] === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
describe("browser aborts for model-backed requests", () => {
|
||||
it("the force regenerate derives its abort from the setting", () => {
|
||||
const source = stripComments(
|
||||
readFileSync(
|
||||
join(SRC, "components/insights/use-insights-advisor.ts"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(source).not.toMatch(/FORCE_ADVISOR_TIMEOUT_MS/);
|
||||
expect(source).toMatch(
|
||||
/options\.force\s*\?\s*forceAdvisorTimeoutMs\(options\.responseTimeoutMs\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/fetchAdvisor\(\{\s*force:\s*true,\s*responseTimeoutMs\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("every about-me save carries the setting-derived signal", () => {
|
||||
const sites: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const rel of walkSourceFiles(SRC, { floor: 3000 })) {
|
||||
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
|
||||
if (!rel.startsWith("components/") && !rel.startsWith("app/")) continue;
|
||||
const source = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
const pattern =
|
||||
/apiPut(?:<[^>]*>)?\s*\(\s*["']\/api\/coach\/about-me["']/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const open = (match.index ?? 0) + match[0].indexOf("(");
|
||||
sites.push(rel);
|
||||
if (
|
||||
!/signal:\s*AbortSignal\.timeout\(saveTimeoutMs\)/.test(
|
||||
callText(source, open),
|
||||
)
|
||||
) {
|
||||
missing.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Six saves today; a matcher that finds none must not read as clean.
|
||||
expect(sites.length).toBeGreaterThanOrEqual(6);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The image optimizer stays off while nothing uses it.
|
||||
*
|
||||
* `next.config.ts` sets `images.unoptimized`, which makes Next answer
|
||||
* `/_next/image` with 404 before it reads a parameter, so the route that
|
||||
* fetches, decodes and re-encodes images is not reachable at all. Two ways
|
||||
* to undo that quietly, and this pins both:
|
||||
*
|
||||
* 1. A component imports `next/image` (or `next/legacy/image`) while the
|
||||
* optimizer is off. It would render, unoptimized, and the next person
|
||||
* to notice the missing optimization would switch the optimizer back on
|
||||
* without the review this flag deserves. Using `next/image` means
|
||||
* revisiting this decision, so the import fails here first.
|
||||
* 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a
|
||||
* document, not a picture, and the framework's own advisories have been
|
||||
* about exactly that input. Re-enabling the optimizer is allowed; doing
|
||||
* it with SVG input allowed is not.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
requireFloor,
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
type ImagesConfig = {
|
||||
unoptimized?: boolean;
|
||||
dangerouslyAllowSVG?: boolean;
|
||||
};
|
||||
|
||||
async function imagesConfig(): Promise<ImagesConfig> {
|
||||
const mod = (await import("../../next.config")) as {
|
||||
default: { images?: ImagesConfig };
|
||||
};
|
||||
return mod.default.images ?? {};
|
||||
}
|
||||
|
||||
// Any import of the module: static, dynamic, `require`, side effect or
|
||||
// re-export. Two literals with one shape rather than one built from a string,
|
||||
// so nothing has to be escaped into a pattern.
|
||||
const NEXT_IMAGE_IMPORT =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/(?:legacy\/)?image["']/m;
|
||||
const NEXT_LINK_IMPORT =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/link["']/m;
|
||||
|
||||
/** Every non-generated, non-test source file under `src/` matching `re`. */
|
||||
function importers(re: RegExp): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
.filter((rel) => !rel.includes("__tests__/"))
|
||||
.filter((rel) =>
|
||||
re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))),
|
||||
);
|
||||
}
|
||||
|
||||
describe("next/image optimizer", () => {
|
||||
it("the import matcher recognises every form of the import", () => {
|
||||
// The sweep below passes when it matches nothing, so the matcher has to
|
||||
// be shown to match before an empty result means anything.
|
||||
for (const line of [
|
||||
'import Image from "next/image";',
|
||||
"import Image from 'next/legacy/image';",
|
||||
'const Image = await import("next/image");',
|
||||
'const Image = require("next/image");',
|
||||
'import "next/image";',
|
||||
'export { default } from "next/image";',
|
||||
]) {
|
||||
expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true);
|
||||
}
|
||||
for (const line of [
|
||||
"// `next/image` is wrong here",
|
||||
'const path = "/_next/image?url=x";',
|
||||
'import { foo } from "next/image-extra";',
|
||||
]) {
|
||||
expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("the same sweep finds the imports of a sibling module", () => {
|
||||
// An empty result for `next/image` is only worth something if the walk,
|
||||
// the comment stripping and the matcher together do find real imports.
|
||||
// `next/link` is imported the same way all over the tree.
|
||||
requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100);
|
||||
});
|
||||
|
||||
it("is off, and then nothing imports next/image", async () => {
|
||||
const images = await imagesConfig();
|
||||
if (images.unoptimized !== true) return;
|
||||
expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is never on with SVG input allowed", async () => {
|
||||
const images = await imagesConfig();
|
||||
if (images.unoptimized === true) return;
|
||||
expect(images.dangerouslyAllowSVG ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it("is off today", async () => {
|
||||
// The two cases above each skip on the other branch. This pins which
|
||||
// branch the tree is on, so flipping it is a visible change to this
|
||||
// file and not a silent switch between the two.
|
||||
expect((await imagesConfig()).unoptimized).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -117,16 +117,19 @@ describe("the shell owns the safe area", () => {
|
||||
// Sonner's own rules are unlayered; the toast lift must stay so.
|
||||
expect(layer).not.toMatch(/data-sonner-toaster/);
|
||||
|
||||
const sheet = code(read("src/components/ui/sheet.tsx"));
|
||||
const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomBranch).toBeDefined();
|
||||
expect(bottomBranch).not.toMatch(/(?<![\w-])bottom-/);
|
||||
const sheet = code(read("src/components/ui/bottom-sheet.tsx"));
|
||||
const bottomClass = sheet.match(/BOTTOM_SHEET_CLASS\s*=\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomClass).toBeDefined();
|
||||
expect(bottomClass).not.toMatch(/(?<![\w-])bottom-/);
|
||||
});
|
||||
|
||||
it("the sheet primitive names its side for those rules", () => {
|
||||
it("the sheet primitives name their side for those rules", () => {
|
||||
expect(read("src/components/ui/sheet.tsx")).toMatch(
|
||||
/data-sheet-side=\{side\}/,
|
||||
);
|
||||
expect(read("src/components/ui/bottom-sheet.tsx")).toMatch(
|
||||
/data-sheet-side="bottom"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import { providerFailureKind } from "@/lib/ai/failure-kind";
|
||||
import {
|
||||
recordBriefingFailure,
|
||||
readBriefingFailure,
|
||||
@@ -855,6 +856,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
: "provider-error",
|
||||
locale,
|
||||
httpStatus: failureHttpStatus,
|
||||
failureKind:
|
||||
e instanceof AllProvidersFailedError
|
||||
? (e.attempts[0]?.failureKind ?? null)
|
||||
: providerFailureKind(e).failure,
|
||||
});
|
||||
if (e instanceof AllProvidersFailedError) {
|
||||
annotate({
|
||||
|
||||
@@ -667,6 +667,37 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* A bottom sheet with a preview step (`BottomSheetContent preview`) is laid
|
||||
* out at its full height and, on the preview step, rests lowered by the
|
||||
* difference: moving between the steps, or dragging, is a transform and
|
||||
* never a relayout. Its footer is lifted by the same amount so it stays on
|
||||
* the window's bottom edge, and its scrolling body ends above that footer,
|
||||
* so nothing in it is out of reach on the lower step. The drag writes the
|
||||
* same three properties inline while a finger is down.
|
||||
*/
|
||||
[data-sheet-snap="preview"] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-height) - var(--sheet-preview-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: transform 260ms ease-out;
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-footer] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-preview-height) - var(--sheet-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-body] {
|
||||
margin-bottom: calc(var(--sheet-height) - var(--sheet-preview-height));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Side and top sheets run the full height of the window. In an installed
|
||||
* app on a tablet the status bar sits over their first 24 px, and a phone
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The SpO₂ chart shades the person's own target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise. The page used to hand the
|
||||
* chart a fixed 80 / 90 / 95 / 100 band, so a target of 88 to 92 % (COPD,
|
||||
* altitude) left every reading of the person's normal range shaded as a
|
||||
* warning.
|
||||
*
|
||||
* The scaffold is replaced by a probe that prints the bands it receives; the
|
||||
* thresholds read is seeded straight into the shared query cell, which is the
|
||||
* cell the target editor invalidates on save.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ValueBand } from "@/lib/analytics/value-bands";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/insights/healthkit-metric-page", () => ({
|
||||
HealthKitMetricPage: (props: { valueBands?: ValueBand[] }) => (
|
||||
<pre data-testid="bands">{JSON.stringify(props.valueBands ?? null)}</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const { default: InsightsOxygenSaturationPage } = await import("../page");
|
||||
|
||||
function bandsRendered(thresholds: unknown): ValueBand[] {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
if (thresholds !== undefined) {
|
||||
client.setQueryData(queryKeys.userThresholds(), thresholds);
|
||||
}
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<InsightsOxygenSaturationPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const match = /<pre[^>]*>([^<]*)<\/pre>/.exec(html);
|
||||
const json = (match?.[1] ?? "null").replaceAll(""", '"');
|
||||
return JSON.parse(json) as ValueBand[];
|
||||
}
|
||||
|
||||
const green = (bands: ValueBand[]) =>
|
||||
bands.find((band) => band.color === "var(--success)");
|
||||
|
||||
describe("/insights/oxygen value bands", () => {
|
||||
it("keeps the clinical bands without a personal target", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 },
|
||||
isOverride: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bands).toEqual([
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the clinical bands while the thresholds read is still loading", () => {
|
||||
expect(green(bandsRendered(undefined))).toEqual(
|
||||
expect.objectContaining({ min: 95, max: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shades the person's own band when they set one", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: {
|
||||
greenMin: 88,
|
||||
greenMax: 92,
|
||||
orangeMin: 87.4,
|
||||
orangeMax: 92.6,
|
||||
},
|
||||
isOverride: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(green(bands)).toEqual(expect.objectContaining({ min: 88, max: 92 }));
|
||||
// Nothing is shaded above 100 %.
|
||||
expect(Math.max(...bands.map((band) => band.max))).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands";
|
||||
import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/oxygen`.
|
||||
@@ -13,8 +15,12 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* compressing the trend. Withings ScanWatch + Apple Watch both
|
||||
* report SpO₂; the v1.4.25 source-priority picker keeps the surface
|
||||
* deduped.
|
||||
*
|
||||
* The shaded zone follows the person's own SpO₂ target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise.
|
||||
*/
|
||||
export default function InsightsOxygenSaturationPage() {
|
||||
const ownRange = useOwnTargetRange("OXYGEN_SATURATION");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="OXYGEN_SATURATION"
|
||||
@@ -26,14 +32,9 @@ export default function InsightsOxygenSaturationPage() {
|
||||
color="var(--chart-4)"
|
||||
unit="%"
|
||||
yAxisUnit="%"
|
||||
valueBands={[
|
||||
// < 90 % — clinical hypoxia floor, paint the danger band.
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
// 90 .. 95 % — watch zone.
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
// 95 .. 100 % — clinical normal.
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]}
|
||||
// The clinical shading (hypoxia floor < 90 %, watch zone to 95 %,
|
||||
// normal above), or the person's own band when they set one.
|
||||
valueBands={buildOxygenSaturationBands(ownRange)}
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="OXYGEN_SATURATION"
|
||||
|
||||
+8
-10
@@ -378,10 +378,10 @@ export default function DashboardPageClient({
|
||||
analyticsThickQuery.data,
|
||||
]);
|
||||
|
||||
// v1.34 — the user's own weight target, for the legacy (snapshot-disabled)
|
||||
// band fallback only. In snapshot mode the server resolves the same override
|
||||
// into `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the height-derived band for someone who has set a target.
|
||||
// The user's own targets, for the legacy (snapshot-disabled) band fallback
|
||||
// only. In snapshot mode the server resolves the same overrides into
|
||||
// `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the default band for someone who has set a target.
|
||||
const { data: thresholdsData } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
@@ -754,9 +754,9 @@ export default function DashboardPageClient({
|
||||
// in the snapshot steady state) before this. Keyed on the `user` object
|
||||
// identity so the React Compiler can preserve the memo (a narrower
|
||||
// property list trips `preserve-manual-memoization`).
|
||||
// v1.34 — the weight target rides the same memo. In snapshot mode
|
||||
// v1.34 — the user's own targets ride the same memo. In snapshot mode
|
||||
// `thresholdsData` is never fetched, so the client copy falls back to the
|
||||
// height-derived band for the frames before `serverBands` lands — exactly
|
||||
// default bands for the frames before `serverBands` lands — exactly
|
||||
// as it already did for every other profile-derived number.
|
||||
// v1.36.x — the profile this fallback reads is the CALLER's, and under a
|
||||
// switch the caller is the delegate. `viewerBandProfile` is where that is
|
||||
@@ -771,9 +771,7 @@ export default function DashboardPageClient({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: toProfileSex(user?.gender),
|
||||
heightCm: user?.heightCm ?? null,
|
||||
weightTargetOverride: resolveWeightTargetOverride(
|
||||
thresholdsData?.overrides ?? null,
|
||||
),
|
||||
thresholdsJson: thresholdsData?.overrides ?? null,
|
||||
},
|
||||
inSharedRecord,
|
||||
),
|
||||
@@ -791,7 +789,7 @@ export default function DashboardPageClient({
|
||||
const weightTrend =
|
||||
(snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ??
|
||||
resolveWeightTrend(
|
||||
clientBandProfile.weightTargetOverride,
|
||||
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
|
||||
weightTrendReferenceKg(w),
|
||||
);
|
||||
// v1.43 — every other tile's direction comes resolved from the server
|
||||
|
||||
@@ -135,6 +135,9 @@ export default function CommandPalette() {
|
||||
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The palette fills the window: no backdrop to pull it towards,
|
||||
// and its own Cancel and Escape close it.
|
||||
draggable={false}
|
||||
showCloseButton={false}
|
||||
data-slot="command-palette"
|
||||
data-variant="sheet"
|
||||
|
||||
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
*
|
||||
* - opening adds exactly one entry, so Back closes the layer;
|
||||
* - opening another day while one is open swaps it in place;
|
||||
* - stepping to the neighbouring day never adds an entry, however often;
|
||||
* - stepping to the neighbouring day never adds an entry, however often,
|
||||
* starts from the day in the URL and never passes today;
|
||||
* - closing a day the layer opened goes Back (the next Back leaves the
|
||||
* page); closing a deep link only drops the parameter and stays.
|
||||
*/
|
||||
@@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) {
|
||||
|
||||
let browser: ReturnType<typeof installFakeBrowser>;
|
||||
|
||||
const TODAY = "2026-01-10";
|
||||
|
||||
beforeEach(() => {
|
||||
browser = installFakeBrowser("/insights/blood-pressure?range=90");
|
||||
});
|
||||
@@ -88,18 +91,63 @@ describe("day layer history", () => {
|
||||
it("steps without adding history entries", async () => {
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", -1);
|
||||
stepDay("2026-01-02", -1);
|
||||
stepDay("2026-01-01", 1);
|
||||
stepDay(-1, TODAY);
|
||||
stepDay(-1, TODAY);
|
||||
stepDay(1, TODAY);
|
||||
expect(browser.length()).toBe(2);
|
||||
expect(browser.current()).toContain("day=2026-01-02");
|
||||
});
|
||||
|
||||
it("steps from the day in the URL, so quick steps in a row all count", async () => {
|
||||
// Nothing republishes the open day between these calls, which is what a
|
||||
// fast key repeat looks like while the layer is still rendering the
|
||||
// previous step. Each step must still move from where the last one went.
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
expect(stepDay(1, TODAY)).toBe("2026-01-04");
|
||||
expect(stepDay(1, TODAY)).toBe("2026-01-05");
|
||||
expect(browser.current()).toContain("day=2026-01-05");
|
||||
});
|
||||
|
||||
it("holds a step while the layer still shows another day, then makes it", async () => {
|
||||
// The layer publishes the day it shows only once Next has adopted the
|
||||
// URL. A step written before that can be put back by Next's own write,
|
||||
// so it waits for the layer and goes in one write when it catches up.
|
||||
const { openDay, publishOpenDay, stepDay } =
|
||||
await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
publishOpenDay("2026-01-03");
|
||||
expect(stepDay(-1, TODAY)).toBe("2026-01-02");
|
||||
// The layer still shows the 3rd: these two wait.
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toContain("day=2026-01-02");
|
||||
publishOpenDay("2026-01-02");
|
||||
expect(browser.current()).toContain("day=2026-01-04");
|
||||
expect(browser.length()).toBe(2);
|
||||
publishOpenDay(null);
|
||||
});
|
||||
|
||||
it("never steps past today, however often it is asked", async () => {
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-09");
|
||||
expect(stepDay(1, TODAY)).toBe(TODAY);
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toContain(`day=${TODAY}`);
|
||||
});
|
||||
|
||||
it("does nothing while no day is open", async () => {
|
||||
const { stepDay } = await import("../day-layer-controller");
|
||||
expect(stepDay(-1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
|
||||
});
|
||||
|
||||
it("closes a pushed day with Back, landing on the page without it", async () => {
|
||||
const { closeDay, openDay, stepDay } =
|
||||
await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", 1);
|
||||
stepDay(1, TODAY);
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
|
||||
});
|
||||
@@ -107,7 +155,7 @@ describe("day layer history", () => {
|
||||
it("closes a deep link in place: the parameter goes, the page stays", async () => {
|
||||
browser = installFakeBrowser("/?day=2026-01-03");
|
||||
const { closeDay, stepDay } = await import("../day-layer-controller");
|
||||
stepDay("2026-01-03", -1);
|
||||
stepDay(-1, TODAY);
|
||||
expect(browser.current()).toBe("/?day=2026-01-02");
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/");
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
dayHistoryState,
|
||||
historyEntryOwnedByLayer,
|
||||
parseDayParam,
|
||||
shiftDateKey,
|
||||
withDayHref,
|
||||
} from "./day-url";
|
||||
@@ -100,6 +101,13 @@ export function publishOpenDay(date: DateKey | null): void {
|
||||
if (memory.open === date) return;
|
||||
memory.open = date;
|
||||
emit();
|
||||
// The layer has caught up with the URL: the steps that waited for it go
|
||||
// now, in one write, from the day it shows.
|
||||
const waiting = pendingStep;
|
||||
pendingStep = null;
|
||||
if (waiting !== null && date !== null) {
|
||||
stepDay(waiting.delta, waiting.today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,9 +163,50 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Move the open day by `delta` calendar days, without a new history entry. */
|
||||
export function stepDay(from: DateKey, delta: number): DateKey {
|
||||
const next = shiftDateKey(from, delta);
|
||||
/**
|
||||
* Steps asked for while the layer had not yet shown the previous one. Kept
|
||||
* as one sum, with the `today` of the latest request.
|
||||
*/
|
||||
let pendingStep: { delta: number; today: DateKey } | null = null;
|
||||
|
||||
/**
|
||||
* Move the open day by `delta` calendar days, without a new history entry,
|
||||
* and never past `today`. Null when nothing is written now: no day is open,
|
||||
* the step would pass today, or it waits for the previous one.
|
||||
*
|
||||
* Two things keep quick steps (a key held down, `]` pressed twice) from
|
||||
* losing one.
|
||||
*
|
||||
* The step starts from the day in the URL, not from the day a caller last
|
||||
* rendered, which reaches it through `useSearchParams`, a render and an
|
||||
* effect and can be a step behind.
|
||||
*
|
||||
* And a step does not write while the layer still shows a different day
|
||||
* than the URL. The write goes past Next's router, which adopts it a render
|
||||
* later, and since Next 16.4 that render can land after a second write and
|
||||
* put the first one's URL back: `[` then `]` pressed quickly ended a day
|
||||
* short. So a step that finds the layer behind waits, and `publishOpenDay`
|
||||
* makes it as soon as the layer shows the day the URL names.
|
||||
*/
|
||||
export function stepDay(delta: number, today: DateKey): DateKey | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const from = parseDayParam(
|
||||
new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM),
|
||||
today,
|
||||
);
|
||||
if (from === null) {
|
||||
pendingStep = null;
|
||||
return null;
|
||||
}
|
||||
const total = (pendingStep?.delta ?? 0) + delta;
|
||||
if (memory.open !== null && memory.open !== from) {
|
||||
pendingStep = { delta: total, today };
|
||||
return null;
|
||||
}
|
||||
pendingStep = null;
|
||||
const target = shiftDateKey(from, total);
|
||||
const next = target > today ? today : target;
|
||||
if (next === from) return null;
|
||||
jumpDay(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -348,8 +348,7 @@ function DayLayer() {
|
||||
const onStep = useCallback(
|
||||
(delta: number) => {
|
||||
if (date === null) return;
|
||||
if (delta > 0 && date >= today) return;
|
||||
stepDay(date, delta);
|
||||
stepDay(delta, today);
|
||||
},
|
||||
[date, today],
|
||||
);
|
||||
@@ -630,9 +629,10 @@ function returnFocus(fallback?: HTMLElement | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal frames. The bottom sheet opens at a bit over half the height and
|
||||
* grows to full height from its handle: drag it up or tap it. Dragging down
|
||||
* shrinks a full sheet, then closes it.
|
||||
* The modal frames. On a phone the bottom sheet opens at a bit over half the
|
||||
* height, so the chart stays in view above it, and grows to the full 90 dvh
|
||||
* when it is pulled up or its grip is tapped (the sheet primitive's preview
|
||||
* step). Pulled down it closes, from either height.
|
||||
*/
|
||||
function DaySheet({
|
||||
shell,
|
||||
@@ -650,51 +650,8 @@ function DaySheet({
|
||||
render: (extra: Partial<DayViewProps>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
const drag = useRef<{ y: number; moved: boolean } | null>(null);
|
||||
const bottom = shell === "bottom";
|
||||
|
||||
const grabber = bottom ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-sheet-handle"
|
||||
aria-label={full ? t("day.sheetShrink") : t("day.sheetExpand")}
|
||||
aria-expanded={full}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { y: event.clientY, moved: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
if (Math.abs(event.clientY - start.y) > 8) start.moved = true;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = drag.current;
|
||||
drag.current = null;
|
||||
if (!start) return;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!start.moved) return;
|
||||
if (dy < -40) setFull(true);
|
||||
else if (dy > 60) {
|
||||
if (full) setFull(false);
|
||||
else onDismiss();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// A drag ends in a click as well; only a tap toggles.
|
||||
if (drag.current?.moved) return;
|
||||
setFull((value) => !value);
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className="bg-muted-foreground/40 h-1.5 w-9 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
@@ -705,10 +662,15 @@ function DaySheet({
|
||||
<SheetContent
|
||||
ref={panelRef as React.Ref<HTMLDivElement>}
|
||||
side={bottom ? "bottom" : "right"}
|
||||
preview={bottom ? "58dvh" : undefined}
|
||||
previewLabels={
|
||||
bottom
|
||||
? { expand: t("day.sheetExpand"), shrink: t("day.sheetShrink") }
|
||||
: undefined
|
||||
}
|
||||
showCloseButton={false}
|
||||
data-slot="day-panel"
|
||||
data-shell={shell}
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTitle();
|
||||
@@ -730,30 +692,18 @@ function DaySheet({
|
||||
if (owner && owner !== panelRef.current) event.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
// Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
|
||||
"bg-card gap-0 p-0",
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
// Full height leaves a strip of the page above the
|
||||
// sheet, and never less than the status bar of an
|
||||
// installed app plus a margin: a 2.5rem strip put the
|
||||
// handle under a 59 px Dynamic Island.
|
||||
full
|
||||
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
|
||||
: "h-[58dvh]",
|
||||
)
|
||||
: "w-105 max-w-[90vw] sm:max-w-105",
|
||||
? "rounded-t-2xl"
|
||||
: // Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
"w-105 max-w-[90vw] data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none sm:max-w-105",
|
||||
)}
|
||||
>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("day.description")}
|
||||
</SheetDescription>
|
||||
{render({
|
||||
above: grabber,
|
||||
headerClassName: bottom ? undefined : "py-3",
|
||||
})}
|
||||
{render({ headerClassName: bottom ? undefined : "py-3" })}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -75,8 +75,6 @@ export interface DayViewProps {
|
||||
}>;
|
||||
titleId: string;
|
||||
titleRef?: Ref<HTMLHeadingElement>;
|
||||
/** Shell chrome above the header (the bottom sheet's grabber). */
|
||||
above?: ReactNode;
|
||||
/** Classes for the header row, which the docked shell aligns to the top bar. */
|
||||
headerClassName?: string;
|
||||
}
|
||||
@@ -103,7 +101,6 @@ export function DayView({
|
||||
Title,
|
||||
titleId,
|
||||
titleRef,
|
||||
above,
|
||||
headerClassName,
|
||||
}: DayViewProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -162,16 +159,20 @@ export function DayView({
|
||||
<div
|
||||
data-slot="day-view"
|
||||
data-day={date}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
// `bg-inherit` passes the sheet's surface down to the footer, which
|
||||
// paints it while it stands over the body (see `day-footer`).
|
||||
className={cn("flex min-h-0 flex-1 flex-col", compact && "bg-inherit")}
|
||||
>
|
||||
{above}
|
||||
<div
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
// The date starts on the body's reading edge (`px-4` / `px-6`).
|
||||
compact
|
||||
? "pt-0.5 pr-2 pb-2 pl-4"
|
||||
? // Under the sheet's grip: the title row's centre lands where
|
||||
// every bottom sheet puts it, 52 px down. The header drags
|
||||
// the sheet, so it never pans the page.
|
||||
"touch-none pt-1.5 pr-2 pb-2 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
headerClassName,
|
||||
)}
|
||||
@@ -257,6 +258,7 @@ export function DayView({
|
||||
|
||||
<div
|
||||
data-slot="day-body"
|
||||
data-sheet-body
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
|
||||
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
|
||||
@@ -342,11 +344,16 @@ export function DayView({
|
||||
{canCapture || (timelineOn && !onTimeline) ? (
|
||||
<div
|
||||
data-slot="day-footer"
|
||||
// On the phone sheet's preview step the footer is held on the
|
||||
// window's bottom edge while the sheet moves (`bottom-sheet.tsx`),
|
||||
// over the body: it carries the sheet's own background for that.
|
||||
data-sheet-footer
|
||||
className={cn(
|
||||
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
|
||||
compact
|
||||
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
|
||||
: "px-6 py-3.5",
|
||||
compact && "bg-inherit",
|
||||
)}
|
||||
>
|
||||
{timelineOn && !onTimeline ? (
|
||||
|
||||
@@ -31,7 +31,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
@@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12;
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
/**
|
||||
* The query string in the address bar, read at render time.
|
||||
*
|
||||
* The open sheet follows `?doc=`, and `useSearchParams` is not a faithful
|
||||
* copy of it: Next 16.4 applies the copy of a `pushState` later than the write,
|
||||
* and a Back pressed in between moves the address bar from `?doc=` to the
|
||||
* page without it while `useSearchParams` can read "no document" on both sides.
|
||||
* The sheet then saw no change and stayed open. `window.location` is never
|
||||
* behind; `popstate` re-reads it on every traversal, and every other render
|
||||
* (including the ones Next's own navigations cause) reads it afresh.
|
||||
*/
|
||||
function subscribeToTraversal(onChange: () => void): () => void {
|
||||
window.addEventListener("popstate", onChange);
|
||||
return () => window.removeEventListener("popstate", onChange);
|
||||
}
|
||||
|
||||
function readLocationSearch(): string {
|
||||
return window.location.search;
|
||||
}
|
||||
|
||||
export function documentSelectionHistoryState(
|
||||
documentId: string,
|
||||
): Record<string, unknown> {
|
||||
@@ -818,7 +845,13 @@ export function DocumentsView() {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const handedOffDocumentRef = useRef<string | null>(null);
|
||||
|
||||
const rawDocParam = searchParams.get("doc");
|
||||
// Before hydration the server's view of the query stands in for the URL.
|
||||
const locationSearch = useSyncExternalStore(
|
||||
subscribeToTraversal,
|
||||
readLocationSearch,
|
||||
() => searchParams.toString(),
|
||||
);
|
||||
const rawDocParam = new URLSearchParams(locationSearch).get("doc");
|
||||
const docParam =
|
||||
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
|
||||
? rawDocParam
|
||||
@@ -837,14 +870,14 @@ export function DocumentsView() {
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => {
|
||||
const href = documentSelectionHref(pathname, searchParams.toString(), id);
|
||||
const href = documentSelectionHref(pathname, window.location.search, id);
|
||||
if (href === null) return;
|
||||
handedOffDocumentRef.current = null;
|
||||
setDetailId(id);
|
||||
setDetailOpen(true);
|
||||
window.history.pushState(documentSelectionHistoryState(id), "", href);
|
||||
},
|
||||
[pathname, searchParams],
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const handleDetailOpenChange = useCallback((open: boolean) => {
|
||||
@@ -881,7 +914,7 @@ export function DocumentsView() {
|
||||
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
|
||||
window.history,
|
||||
pathname,
|
||||
searchParams.toString(),
|
||||
window.location.search,
|
||||
docParam,
|
||||
handedOffDocumentRef.current,
|
||||
coachLaunch?.closeIntent,
|
||||
@@ -925,7 +958,6 @@ export function DocumentsView() {
|
||||
docParam,
|
||||
moduleEnabled,
|
||||
pathname,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Hover/focus intent prefetches the detail METADATA (never the blob —
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* button reads as doing nothing (one half of the recurring
|
||||
* "no briefing today" chain). The read path stays a plain GET.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { fetchAdvisor } from "../use-insights-advisor";
|
||||
import { fetchAdvisor, forceAdvisorTimeoutMs } from "../use-insights-advisor";
|
||||
|
||||
function jsonResponse(data: unknown): Response {
|
||||
return {
|
||||
@@ -74,3 +74,56 @@ describe("fetchAdvisor — explicit regenerate forces a generation", () => {
|
||||
expect(result.payload?.dailyBriefing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdvisor — the regenerate waits as long as the response timeout allows (#1267)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** A POST that only ends when its signal aborts, like a slow model. */
|
||||
function hangUntilAborted() {
|
||||
apiFetchRaw.mockImplementation(
|
||||
(_url: string, init: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () =>
|
||||
reject(new DOMException("aborted", "AbortError")),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("is still waiting at 45 s and at 10 min with a 600 s setting", async () => {
|
||||
vi.useFakeTimers();
|
||||
hangUntilAborted();
|
||||
let settled: unknown = null;
|
||||
void fetchAdvisor({ force: true, responseTimeoutMs: 600_000 }).then(
|
||||
(r) => (settled = r),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(45_001);
|
||||
expect(settled).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
expect(settled).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(forceAdvisorTimeoutMs(600_000));
|
||||
expect(settled).toEqual({ payload: null, outcome: "timeout" });
|
||||
});
|
||||
|
||||
it("never waits less than the briefing's own 180 s per call", () => {
|
||||
// 60 s is what the payload publishes when nothing is set; the route still
|
||||
// gives each briefing call 180 s.
|
||||
expect(forceAdvisorTimeoutMs(60_000)).toBeGreaterThanOrEqual(2 * 180_000);
|
||||
expect(forceAdvisorTimeoutMs(undefined)).toBe(
|
||||
forceAdvisorTimeoutMs(60_000),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the read path at its fast 8 s bound", async () => {
|
||||
vi.useFakeTimers();
|
||||
hangUntilAborted();
|
||||
let settled: unknown = null;
|
||||
void fetchAdvisor({ responseTimeoutMs: 600_000 }).then(
|
||||
(r) => (settled = r),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(8_001);
|
||||
expect(settled).toEqual({ payload: null, outcome: "timeout" });
|
||||
});
|
||||
});
|
||||
@@ -713,7 +713,7 @@ function InsightsTabStripImpl({
|
||||
//
|
||||
// Refs #786 — the edge watches the COMBINED busy state (`regenerating ||
|
||||
// regenerateSettling`): a timed-out regenerate flows straight into
|
||||
// settling, so the spinner never falls at 45 s and NO error toast fires
|
||||
// settling, so the spinner never falls at the client abort and NO error toast fires
|
||||
// while the server may still deliver. The edge fires once settling
|
||||
// resolves, with the proven outcome — `"fresh"` (success) or
|
||||
// `"settle-failed"` (its own honest error copy). The mapping is the pure
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAiCapability, useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { clientAbortMs } from "@/lib/ai/effective-timeout";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
/**
|
||||
@@ -116,16 +118,29 @@ const ADVISOR_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* v1.15.18 — the user-initiated regenerate (`force`) POSTs an INLINE
|
||||
* generation: a ~1500-token warm completion that routinely runs longer
|
||||
* than the 8 s read budget. Bounding the force branch at 8 s silently
|
||||
* discarded a slow-but-successful generation — the abort returned a null
|
||||
* payload, the mutation only invalidated (re-reading the OLD cache), yet
|
||||
* the server had often already written the fresh briefing. Give the force
|
||||
* branch a generous 45 s client budget so a slow success is kept, not
|
||||
* dropped. The READ path stays fast at 8 s — only the explicit user tap
|
||||
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
|
||||
* generation that routinely runs longer than the 8 s read budget; bounding it
|
||||
* at 8 s discarded a slow-but-successful generation.
|
||||
*
|
||||
* #1267 — the budget follows the person's response-timeout setting instead of
|
||||
* a constant 45 s, which cut every regenerate on a slow local model while the
|
||||
* server still had minutes left. The route makes up to two sequential model
|
||||
* calls (the generation and its grounding retry), and a reasoning model that
|
||||
* answered nothing is asked once more inside the first, so three calls at the
|
||||
* per-call timeout, at least the briefing's own 180 s default each. Never
|
||||
* shorter than the server: the abort is only for a request nobody answers.
|
||||
*/
|
||||
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
|
||||
const FORCE_ADVISOR_MODEL_CALLS = 3;
|
||||
|
||||
/** The client budget for a forced regenerate (exported for the test). */
|
||||
export function forceAdvisorTimeoutMs(
|
||||
responseTimeoutMs: number | null | undefined,
|
||||
): number {
|
||||
return clientAbortMs(
|
||||
responseTimeoutMs,
|
||||
FORCE_ADVISOR_MODEL_CALLS,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.7 — poll cadence + ceiling while the server reports
|
||||
@@ -146,7 +161,7 @@ export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
|
||||
*
|
||||
* Async-truth contract, client end (the server end is documented at the head
|
||||
* of `src/app/api/insights/generate/route.ts`): the force POST generates
|
||||
* INLINE and keeps running after the client aborts at 45 s — the server
|
||||
* INLINE and keeps running after the client aborts — the server
|
||||
* usually writes the fresh cache moments later. A page that knows work is
|
||||
* running must keep saying so until it can prove an outcome, so the timeout
|
||||
* is NOT surfaced as an error. Instead the hook remembers the baseline it was
|
||||
@@ -276,12 +291,18 @@ interface AdvisorFetchResult {
|
||||
* no-briefing-today chain). The wire-contract test pins it.
|
||||
*/
|
||||
export async function fetchAdvisor(
|
||||
options: { force?: boolean } = {},
|
||||
options: {
|
||||
force?: boolean;
|
||||
/** `ai.provider.responseTimeoutMs` from the account payload. */
|
||||
responseTimeoutMs?: number | null;
|
||||
} = {},
|
||||
): Promise<AdvisorFetchResult> {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.force ? FORCE_ADVISOR_TIMEOUT_MS : ADVISOR_TIMEOUT_MS,
|
||||
options.force
|
||||
? forceAdvisorTimeoutMs(options.responseTimeoutMs)
|
||||
: ADVISOR_TIMEOUT_MS,
|
||||
);
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -437,6 +458,7 @@ export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
): UseInsightsAdvisorResult {
|
||||
const briefing = useAiCapability("briefing");
|
||||
const { responseTimeoutMs } = useAiProviderState();
|
||||
const queryClient = useQueryClient();
|
||||
// Refs #786 — settling state for a timed-out force regenerate (see the
|
||||
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
|
||||
@@ -500,7 +522,7 @@ export function useInsightsAdvisorQuery(
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => fetchAdvisor({ force: true }),
|
||||
mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }),
|
||||
onSuccess: (result) => {
|
||||
if (result.payload) {
|
||||
// A genuinely fresh generation landed — write it into the shared
|
||||
@@ -509,7 +531,7 @@ export function useInsightsAdvisorQuery(
|
||||
} else {
|
||||
// No fresh payload (timeout / no-provider / transient). The server's
|
||||
// inline POST may STILL have written a fresh briefing after the
|
||||
// client gave up at 45 s, so re-read the GET to converge — but the
|
||||
// client gave up, so re-read the GET to converge — but the
|
||||
// honest toast is gated on `regenerateOutcome` below, not on this
|
||||
// invalidate.
|
||||
//
|
||||
|
||||
@@ -274,7 +274,9 @@ export function BottomNav() {
|
||||
// The sheet auto-sizes to its content; padding-bottom respects
|
||||
// the iOS safe area so the inner buttons stay reachable above
|
||||
// the home-indicator on iPhone-X-class devices.
|
||||
className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
// `gap-0`: the grip sits flush above the header, and the grid
|
||||
// takes the header's old 16 px gap as its own top padding.
|
||||
className="shell-desktop:hidden mx-auto max-w-md gap-0 rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
data-testid="bottom-nav-more-sheet"
|
||||
>
|
||||
<SheetHeader>
|
||||
@@ -286,7 +288,7 @@ export function BottomNav() {
|
||||
(de "Benachrichtigungen") then have room to break onto a clean
|
||||
second line instead of clipping the last character at the tile
|
||||
edge. */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
|
||||
@@ -192,12 +192,12 @@ export function GlobalShortcuts() {
|
||||
setShortcutsHelpOpen(true);
|
||||
return;
|
||||
case "day": {
|
||||
const date = state.openDay;
|
||||
if (date === null) return;
|
||||
if (state.openDay === null) return;
|
||||
event.preventDefault();
|
||||
// No day after today: the layer refuses it, so the step does too.
|
||||
if (action.delta > 0 && date >= state.today) return;
|
||||
stepDay(date, action.delta);
|
||||
// The step reads the day from the URL, which is ahead of
|
||||
// `openDay` while a previous step is still rendering, and refuses
|
||||
// a day after today itself.
|
||||
stepDay(action.delta, state.today);
|
||||
return;
|
||||
}
|
||||
case "day-toggle":
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
AnamnesisCard,
|
||||
@@ -153,6 +154,7 @@ export function BaselineForm({
|
||||
allergies: "",
|
||||
});
|
||||
const [baseAboutMe, setBaseAboutMe] = useState("");
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -229,7 +231,9 @@ export function BaselineForm({
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
await apiPut("/api/coach/about-me", aboutMeBody, {
|
||||
signal: AbortSignal.timeout(saveTimeoutMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
await onConfirmed();
|
||||
|
||||
@@ -28,6 +28,13 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// #1267 — the about-me save's abort reads the account payload; the
|
||||
// react-query mock above has no client to read it from.
|
||||
vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-ai-capability")>()),
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
@@ -32,6 +32,14 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// #1267 — the about-me save's abort reads the account payload; the
|
||||
// react-query mock above has no client to read it from.
|
||||
vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-ai-capability")>()),
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
|
||||
@@ -38,7 +38,10 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
useAboutMeSaveTimeoutMs,
|
||||
useAiCapability,
|
||||
} from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -82,6 +85,7 @@ export function AboutMeNoteManager() {
|
||||
// there is a note to clear.
|
||||
const hasNote = savedNote.length > 0;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
// Send only the note. `conditions` / `coachFocus` / `allergies` are
|
||||
@@ -93,6 +97,7 @@ export function AboutMeNoteManager() {
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
isConflict,
|
||||
readUpdatedAtToken,
|
||||
@@ -72,6 +73,7 @@ export function AiProfileInclusionManager() {
|
||||
(section) => draft.includes(section) !== saved.includes(section),
|
||||
);
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: (sections: HealthProfileAiSection[]) =>
|
||||
@@ -84,6 +86,7 @@ export function AiProfileInclusionManager() {
|
||||
readUpdatedAtToken(queryClient, queryKeys.coachAboutMe()) ??
|
||||
undefined,
|
||||
),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
|
||||
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
@@ -75,6 +76,7 @@ export function AllergyFreeTextNote({
|
||||
const value = draft ?? savedLine;
|
||||
const dirty = value !== savedLine;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
// Send only the allergies line — omitted fields are preserved server-side
|
||||
@@ -84,6 +86,7 @@ export function AllergyFreeTextNote({
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
|
||||
@@ -76,13 +77,18 @@ export function ConditionsManager() {
|
||||
value.conditions !== saved.conditions ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (input: { conditions: string; coachFocus: string }) =>
|
||||
apiPut<AboutMeData>("/api/coach/about-me", {
|
||||
conditions: input.conditions,
|
||||
coachFocus: input.coachFocus,
|
||||
}),
|
||||
apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
{
|
||||
conditions: input.conditions,
|
||||
coachFocus: input.coachFocus,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.conditions.savedToast"));
|
||||
setDrafts({ conditions: null, coachFocus: null });
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -90,12 +91,14 @@ export function FreeTextTakeOver({
|
||||
showErrorToast(t("records.life.takeOver.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const clear = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: () =>
|
||||
apiPut(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
}),
|
||||
// v1.41 — the thinking depth reads the resolved block; none here.
|
||||
useCoachReasoning: () => null,
|
||||
// #1267 — the about-me save's abort, from the response-timeout setting.
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
// #1267 — the about-me save's abort, from the response-timeout setting.
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -99,6 +99,12 @@ describe.each([
|
||||
expect(select).toMatch(/<option value="" selected="">/);
|
||||
});
|
||||
|
||||
it("says the daily briefing does not follow this setting (#1267)", () => {
|
||||
const html = render(form(provider({})));
|
||||
expect(html).toContain('data-slot="reasoning-effort-background-note"');
|
||||
expect(html).toContain(en.settings.ai.reasoning.backgroundNote);
|
||||
});
|
||||
|
||||
it("seeds the stored value", () => {
|
||||
const select = selectMarkup(render(form(provider(stored("none")))), id);
|
||||
expect(select).toMatch(/<option value="none" selected="">/);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Same Label + NativeSelect + hint layout as the model field above it.
|
||||
* The empty value is Default: nothing is sent and the model decides.
|
||||
* v1.41 — the Coach reads its own thinking depth from the Coach settings;
|
||||
* a line under the hint says so.
|
||||
* a line under the hint says so. #1267 — so do the background jobs (the
|
||||
* daily briefing among them); a second line says that too.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -66,6 +67,15 @@ export function ReasoningEffortField({
|
||||
>
|
||||
{t("settings.ai.reasoning.coachNote")}
|
||||
</p>
|
||||
{/* #1267 — background jobs resolve their own level on the server
|
||||
(`BACKGROUND_REASONING_LEVEL`, the operator's cap, the usage level);
|
||||
this select never reaches them, so say so where it is set. */}
|
||||
<p
|
||||
data-slot="reasoning-effort-background-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("settings.ai.reasoning.backgroundNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The bottom sheet's anatomy, rendered statically: the grip, the 44 px X on
|
||||
* the title row, the preview step's wiring, and that every bottom sheet
|
||||
* (`<SheetContent side="bottom">`) takes this path while the other sides do
|
||||
* not. Radix portals do not render on the server, so its Dialog parts are
|
||||
* passed through as plain elements; what Radix itself does (focus, Escape,
|
||||
* the backdrop) and the drag in a browser are `e2e/bottom-sheet.spec.ts`.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("radix-ui", () => {
|
||||
type P = { children?: ReactNode; [key: string]: unknown };
|
||||
const strip = ({ asChild: _asChild, ...rest }: P) => rest;
|
||||
return {
|
||||
Dialog: {
|
||||
Root: ({ children }: P) => <>{children}</>,
|
||||
Portal: ({ children }: P) => <>{children}</>,
|
||||
Overlay: (props: P) => <div data-mock="overlay" {...strip(props)} />,
|
||||
Content: ({ children, ...props }: P) => (
|
||||
<div role="dialog" {...strip(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Close: ({ children, ...props }: P) => (
|
||||
<button type="button" {...strip(props)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Title: ({ children }: P) => <h2>{children}</h2>,
|
||||
Description: ({ children }: P) => <p>{children}</p>,
|
||||
Trigger: ({ children }: P) => <>{children}</>,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { BottomSheetContent, BOTTOM_SHEET_CLASS } =
|
||||
await import("../bottom-sheet");
|
||||
const { SheetContent } = await import("../sheet");
|
||||
|
||||
function attr(html: string, slot: string): string {
|
||||
const match = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
describe("<BottomSheetContent>", () => {
|
||||
it("draws a decorative grip and a 44 px X on the title row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toContain('aria-hidden="true"');
|
||||
expect(grip).toContain("h-6");
|
||||
expect(grip).toContain("touch-none");
|
||||
expect(html).toContain("h-1.25 w-9 rounded-full");
|
||||
const close = attr(html, "sheet-close-button");
|
||||
expect(close).toContain("size-11");
|
||||
expect(close).toContain("top-7.5");
|
||||
expect(html).toContain("common.close");
|
||||
// The swipe closes through a Radix Close of its own, out of reach.
|
||||
const swipe = attr(html, "sheet-swipe-close");
|
||||
expect(swipe).toContain("hidden");
|
||||
expect(swipe).toContain('tabindex="-1"');
|
||||
});
|
||||
|
||||
it("keeps its backdrop out of touch panning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
expect(attr(html, "sheet-overlay")).toContain("touch-none");
|
||||
});
|
||||
|
||||
it("drops the grip, the drag and the X offset when it is not draggable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent draggable={false}>body</BottomSheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain('data-slot="sheet-swipe-close"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3");
|
||||
});
|
||||
|
||||
it("opens a preview sheet on its preview step, with a grip that expands it", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent
|
||||
preview="58dvh"
|
||||
previewLabels={{ expand: "Expand", shrink: "Shrink" }}
|
||||
>
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
const content = attr(html, "sheet-content");
|
||||
expect(content).toContain('data-sheet-snap="preview"');
|
||||
expect(content).toContain("--sheet-height:90dvh");
|
||||
expect(content).toContain("--sheet-preview-height:58dvh");
|
||||
expect(content).toContain("h-(--sheet-height)");
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toMatch(/^<button/);
|
||||
expect(grip).toContain('aria-label="Expand"');
|
||||
expect(grip).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it("names its side for the keyboard rule and lets a consumer slot win", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent data-slot="mine" className="bg-card gap-0">
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
expect(html).toContain('data-slot="mine"');
|
||||
const content = attr(html, "mine");
|
||||
expect(content).toMatch(/\bbg-card\b/);
|
||||
expect(content).not.toContain("bg-background");
|
||||
expect(content).not.toMatch(/\bgap-4\b/);
|
||||
});
|
||||
|
||||
it("moves, opens and closes on its own timing and stays still under reduced motion", () => {
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=open]:duration-260");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=closed]:duration-220");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("ease-out");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("motion-reduce:animate-none");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("max-h-[90dvh]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SheetContent>", () => {
|
||||
it("renders every bottom sheet through the bottom sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="bottom">body</SheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sheet-grip"');
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
});
|
||||
|
||||
it("leaves the side sheets as they were", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="right" preview="58dvh">
|
||||
body
|
||||
</SheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain("data-sheet-snap");
|
||||
expect(html).toContain('data-sheet-side="right"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3 right-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { useSheetDrag } from "@/hooks/use-sheet-drag";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SheetSnap } from "@/lib/sheet-gesture";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bottom sheet: every `<SheetContent side="bottom">` renders through
|
||||
* here, and so does `ResponsiveSheet` on a phone.
|
||||
*
|
||||
* It is Radix's Dialog with a drag on top. Focus moving in, the focus trap,
|
||||
* Escape, the backdrop tap, `role="dialog"` and its name, and the scroll
|
||||
* lock on the page behind all stay Radix's. What this adds:
|
||||
*
|
||||
* - focus goes back to whatever opened the sheet (Radix only knows a
|
||||
* `Dialog.Trigger`, which most openers are not), unless the caller's
|
||||
* `onCloseAutoFocus` places it itself; and `aria-modal`;
|
||||
* - a grip at the top edge; the grip and the header drag the sheet always,
|
||||
* the content only while it is scrolled to its very top
|
||||
* (`use-sheet-drag.ts`, rules in `lib/sheet-gesture.ts`);
|
||||
* - a release past a quarter of the height, or a flick down, closes it,
|
||||
* anything less springs back; sideways movement never counts;
|
||||
* - the backdrop lightens as the sheet goes down, and neither it nor the
|
||||
* grip and header ever scroll or pull the page behind;
|
||||
* - opt in, a preview step (`preview`): the sheet opens at that height and
|
||||
* a pull up, or a tap on the grip, takes it to the full 90 dvh. Its
|
||||
* footer (`data-sheet-footer`) stays on the window's bottom edge and its
|
||||
* scrolling body (`data-sheet-body`) ends above it on either step.
|
||||
*
|
||||
* The X sits on the title row, 44 px square; the keyboard and a screen
|
||||
* reader close the sheet through it or Escape, never through a gesture.
|
||||
*/
|
||||
|
||||
export interface BottomSheetOptions {
|
||||
/**
|
||||
* Opens the sheet at this height (a CSS length, e.g. `58dvh`) with the
|
||||
* full 90 dvh one pull up. Needs `previewLabels` for the grip, which then
|
||||
* becomes a button that toggles the two steps.
|
||||
*/
|
||||
preview?: string;
|
||||
previewLabels?: { expand: string; shrink: string };
|
||||
/**
|
||||
* False leaves out the grip and the drag, for a sheet that fills the whole
|
||||
* window (the command palette): there is no backdrop to pull it towards.
|
||||
*/
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export type BottomSheetContentProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Content
|
||||
> &
|
||||
BottomSheetOptions & { showCloseButton?: boolean };
|
||||
|
||||
/**
|
||||
* No `bottom-*` here: the sheet's `bottom` comes from the keyboard rule in
|
||||
* `globals.css`, which a utility would outrank. In 260 ms, out 220 ms, both
|
||||
* easing out; no slide at all under reduced motion. The `after:` strip
|
||||
* continues the sheet's own background below its edge, so the short
|
||||
* resisting travel above the top never shows the page underneath.
|
||||
*/
|
||||
export const BOTTOM_SHEET_CLASS =
|
||||
"bg-background fixed inset-x-0 z-50 flex max-h-[90dvh] flex-col gap-4 border-t shadow-lg transition-transform ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none motion-reduce:transition-none after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-10 after:bg-inherit after:content-['']";
|
||||
|
||||
const OVERLAY_CLASS =
|
||||
"fixed inset-0 z-50 touch-none bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ease-out data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none";
|
||||
|
||||
/** Puts a preview sheet back on its preview step each time it opens. */
|
||||
function OnOpen({ run }: { run: () => void }) {
|
||||
React.useLayoutEffect(() => {
|
||||
run();
|
||||
// Once per mount, which is once per opening.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SheetGrip({
|
||||
snap,
|
||||
labels,
|
||||
onToggle,
|
||||
}: {
|
||||
snap: SheetSnap;
|
||||
labels?: { expand: string; shrink: string };
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const bar = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 h-1.25 w-9 rounded-full"
|
||||
/>
|
||||
);
|
||||
if (labels) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="sheet-grip"
|
||||
aria-label={snap === "full" ? labels.shrink : labels.expand}
|
||||
aria-expanded={snap === "full"}
|
||||
onClick={onToggle}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{bar}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-grip"
|
||||
aria-hidden="true"
|
||||
className="flex h-6 shrink-0 touch-none items-center justify-center"
|
||||
>
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomSheetContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable = true,
|
||||
ref,
|
||||
style,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onClickCapture,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...props
|
||||
}: BottomSheetContentProps) {
|
||||
const { t } = useTranslations();
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const probeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const closeRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
// What had focus when the sheet opened. Radix hands focus back only to a
|
||||
// `Dialog.Trigger`, and most bottom sheets open from a plain button (the
|
||||
// More tab, a chart's tooltip), so focus fell to the body on close.
|
||||
const openerRef = React.useRef<HTMLElement | null>(null);
|
||||
const initial: SheetSnap = preview ? "preview" : "full";
|
||||
const [snap, setSnap] = React.useState<SheetSnap>(initial);
|
||||
const [node, setNode] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const drag = useSheetDrag({
|
||||
enabled: draggable,
|
||||
hasPreview: !!preview,
|
||||
snap,
|
||||
onSnap: setSnap,
|
||||
onClose: () => closeRef.current?.click(),
|
||||
contentRef,
|
||||
overlayRef,
|
||||
probeRef,
|
||||
node,
|
||||
});
|
||||
|
||||
const setContent = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
setNode(node);
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Portal data-slot="sheet-portal">
|
||||
<SheetPrimitive.Overlay
|
||||
ref={overlayRef}
|
||||
data-slot="sheet-overlay"
|
||||
className={OVERLAY_CLASS}
|
||||
/>
|
||||
<SheetPrimitive.Content
|
||||
ref={setContent}
|
||||
data-slot="sheet-content"
|
||||
// Read by the keyboard rule in `globals.css`: a bottom sheet stands
|
||||
// on the on-screen keyboard. Its own attribute rather than
|
||||
// `data-slot`, which consumers replace with theirs.
|
||||
data-sheet-side="bottom"
|
||||
data-sheet-snap={preview ? snap : undefined}
|
||||
// Radix makes the page behind inert and traps focus; this says so
|
||||
// to assistive technology that reads the attribute rather than the
|
||||
// hidden siblings.
|
||||
aria-modal="true"
|
||||
style={
|
||||
preview
|
||||
? ({
|
||||
"--sheet-height": "90dvh",
|
||||
"--sheet-preview-height": preview,
|
||||
...style,
|
||||
} as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
BOTTOM_SHEET_CLASS,
|
||||
preview && "h-(--sheet-height) max-h-(--sheet-height)",
|
||||
className,
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
drag.onPointerDown?.(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
onPointerMove?.(event);
|
||||
drag.onPointerMove?.(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
onPointerUp?.(event);
|
||||
drag.onPointerUp?.(event);
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
onPointerCancel?.(event);
|
||||
drag.onPointerCancel?.(event);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
drag.onClickCapture?.(event);
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
const active = document.activeElement;
|
||||
openerRef.current =
|
||||
active instanceof HTMLElement && active !== document.body
|
||||
? active
|
||||
: null;
|
||||
onOpenAutoFocus?.(event);
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
onCloseAutoFocus?.(event);
|
||||
const opener = openerRef.current;
|
||||
if (event.defaultPrevented || !opener?.isConnected) return;
|
||||
event.preventDefault();
|
||||
opener.focus({ preventScroll: true });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{preview ? (
|
||||
<>
|
||||
<OnOpen run={() => setSnap(initial)} />
|
||||
<div
|
||||
ref={probeRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute top-0 left-0 w-px"
|
||||
style={{ height: preview }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
<SheetGrip
|
||||
snap={snap}
|
||||
labels={preview ? previewLabels : undefined}
|
||||
onToggle={() => setSnap(snap === "full" ? "preview" : "full")}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className={cn(
|
||||
// On the title row: the grip's 24 px plus the header's 16 px
|
||||
// top padding put the title's line centre 52 px down.
|
||||
draggable ? "top-7.5" : "top-3",
|
||||
"ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute right-2 inline-flex size-11 touch-none items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
// What a swipe presses: the same close as the X, so the sheet's
|
||||
// `onOpenChange` hears one kind of close however it was asked.
|
||||
<SheetPrimitive.Close
|
||||
ref={closeRef}
|
||||
hidden
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-slot="sheet-swipe-close"
|
||||
/>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,9 @@ export function ResponsiveSheet({
|
||||
<SheetHeader
|
||||
data-slot="responsive-sheet-header"
|
||||
className={cn(
|
||||
"border-border/70 gap-1.5 border-b p-4",
|
||||
// Under the sheet's grip; the header drags the sheet, so a
|
||||
// touch on it never pans or pulls the page.
|
||||
"border-border/70 touch-none gap-1.5 border-b p-4",
|
||||
// Reserve the right gutter ONLY when the primitive paints its
|
||||
// absolute close button. With `showCloseButton={false}` the
|
||||
// consumer's own trailing control (headerAction) sits flush to
|
||||
|
||||
+41
-14
@@ -6,6 +6,10 @@ import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
BottomSheetContent,
|
||||
type BottomSheetOptions,
|
||||
} from "@/components/ui/bottom-sheet";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -45,14 +49,43 @@ function SheetOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet from one edge. `side="bottom"` is the bottom sheet
|
||||
* (`bottom-sheet.tsx`): a grip, the drag, and the options in
|
||||
* `BottomSheetOptions`, which the other sides ignore.
|
||||
*/
|
||||
function SheetContent({
|
||||
side = "right",
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
BottomSheetOptions & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
if (side === "bottom") {
|
||||
return (
|
||||
<BottomSheetContent
|
||||
preview={preview}
|
||||
previewLabels={previewLabels}
|
||||
draggable={draggable}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <EdgeSheetContent side={side} {...props} />;
|
||||
}
|
||||
|
||||
function EdgeSheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
side,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
side: "top" | "right" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,14 +94,10 @@ function SheetContent({
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
// Read by the safe-area and on-screen-keyboard rules in
|
||||
// `globals.css`: a bottom sheet stands on the keyboard, the
|
||||
// full-height sides keep clear of the status bar and the notch.
|
||||
// Its own attribute rather than `data-slot`, which consumers
|
||||
// replace with theirs (`responsive-sheet-content`, `day-panel`).
|
||||
// A bottom sheet's `bottom` comes from that rule (0 until the
|
||||
// keyboard opens), so its branch below carries no `bottom-*`: a
|
||||
// utility would outrank the layered rule and pin it to the edge.
|
||||
// Read by the safe-area rules in `globals.css`: the full-height
|
||||
// sides keep clear of the status bar and the notch. Its own
|
||||
// attribute rather than `data-slot`, which consumers replace with
|
||||
// theirs.
|
||||
data-sheet-side={side}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
@@ -78,8 +107,6 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -90,8 +117,8 @@ function SheetContent({
|
||||
// a 36 px floor, matching the Dialog close-X treatment from
|
||||
// MB2 (Decision I). 36 px (not 44 px) is the intentional
|
||||
// exception for fixed icon-only close affordances per the
|
||||
// dialog primitive's rationale: 44 px would crowd the sheet
|
||||
// header out of proportion on the bottom-sheet branch.
|
||||
// dialog primitive's rationale. The bottom sheet's X is 44 px
|
||||
// on its title row (`bottom-sheet.tsx`).
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none sm:min-h-9 sm:min-w-9 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SHEET_SETTLE_MS,
|
||||
createSheetDrag,
|
||||
type SheetDragEvent,
|
||||
} from "../use-sheet-drag";
|
||||
import type { SheetSnap } from "@/lib/sheet-gesture";
|
||||
|
||||
/**
|
||||
* The drag controller against stand-in elements: what it writes to the
|
||||
* sheet, the backdrop and the footer, and when it closes, settles or leaves
|
||||
* the touch alone. The browser-level proof (real touches, scrolling, focus)
|
||||
* is `e2e/bottom-sheet.spec.ts` on WebKit.
|
||||
*/
|
||||
|
||||
interface FakeEl {
|
||||
style: Record<string, string>;
|
||||
offsetHeight: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollTop: number;
|
||||
parentElement: FakeEl | null;
|
||||
overflowY: string;
|
||||
own?: boolean;
|
||||
closest: (selector: string) => FakeEl | null;
|
||||
getAnimations: () => Animation[];
|
||||
setPointerCapture: (id: number) => void;
|
||||
querySelector: (selector: string) => FakeEl | null;
|
||||
}
|
||||
|
||||
function fake(partial: Partial<FakeEl> = {}): FakeEl {
|
||||
const node: FakeEl = {
|
||||
style: {},
|
||||
offsetHeight: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
overflowY: "visible",
|
||||
closest: () => (node.own ? node : null),
|
||||
getAnimations: () => [],
|
||||
setPointerCapture: vi.fn(),
|
||||
querySelector: () => null,
|
||||
...partial,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function setup({
|
||||
preview = false,
|
||||
height = 400,
|
||||
previewHeight = 250,
|
||||
}: { preview?: boolean; height?: number; previewHeight?: number } = {}) {
|
||||
let clock = 0;
|
||||
let snap: SheetSnap = preview ? "preview" : "full";
|
||||
const frames: Array<() => void> = [];
|
||||
const timers: Array<() => void> = [];
|
||||
const footer = fake();
|
||||
const content = fake({
|
||||
offsetHeight: height,
|
||||
querySelector: () => footer,
|
||||
});
|
||||
const header = fake({ parentElement: content });
|
||||
const body = fake({
|
||||
parentElement: content,
|
||||
overflowY: "auto",
|
||||
scrollHeight: 2000,
|
||||
clientHeight: 300,
|
||||
});
|
||||
const row = fake({ parentElement: body });
|
||||
const overlay = fake();
|
||||
const probe = fake({ offsetHeight: previewHeight });
|
||||
const onClose = vi.fn();
|
||||
const onSnap = vi.fn((next: SheetSnap) => {
|
||||
snap = next;
|
||||
});
|
||||
const controller = createSheetDrag({
|
||||
content: () => content as unknown as HTMLElement,
|
||||
overlay: () => overlay as unknown as HTMLElement,
|
||||
probe: () => (preview ? (probe as unknown as HTMLElement) : null),
|
||||
hasPreview: () => preview,
|
||||
snap: () => snap,
|
||||
onSnap,
|
||||
onClose,
|
||||
now: () => clock,
|
||||
frame: (callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
later: (callback) => {
|
||||
timers.push(callback);
|
||||
},
|
||||
reducedMotion: () => false,
|
||||
computedTransform: (element) => {
|
||||
const inline = (element as unknown as FakeEl).style.transform;
|
||||
if (inline) {
|
||||
const y = Number(/,\s*(-?[\d.]+)px/.exec(inline)?.[1] ?? 0);
|
||||
return `matrix(1, 0, 0, 1, 0, ${y})`;
|
||||
}
|
||||
return snap === "preview"
|
||||
? `matrix(1, 0, 0, 1, 0, ${height - previewHeight})`
|
||||
: "none";
|
||||
},
|
||||
overflowY: (element) => (element as unknown as FakeEl).overflowY,
|
||||
});
|
||||
|
||||
const ev = (
|
||||
target: FakeEl,
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<SheetDragEvent> = {},
|
||||
): SheetDragEvent => ({
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
target: target as unknown as EventTarget,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A drag of `dy` in `steps` moves, `stepMs` apart, then an optional rest. */
|
||||
function drag(
|
||||
target: FakeEl,
|
||||
dy: number,
|
||||
{ steps = 10, stepMs = 16, dx = 0, restMs = 0 } = {},
|
||||
) {
|
||||
controller.pointerDown(ev(target, 100, 100));
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
clock += stepMs;
|
||||
controller.pointerMove(
|
||||
ev(target, 100 + (dx * i) / steps, 100 + (dy * i) / steps),
|
||||
);
|
||||
}
|
||||
clock += restMs;
|
||||
controller.pointerUp(ev(target, 100 + dx, 100 + dy));
|
||||
}
|
||||
|
||||
const flushFrames = () => {
|
||||
while (frames.length) frames.shift()!();
|
||||
};
|
||||
|
||||
return {
|
||||
controller,
|
||||
content,
|
||||
header,
|
||||
body,
|
||||
row,
|
||||
overlay,
|
||||
footer,
|
||||
onClose,
|
||||
onSnap,
|
||||
frames,
|
||||
timers,
|
||||
flushFrames,
|
||||
drag,
|
||||
ev,
|
||||
getSnap: () => snap,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSheetDrag", () => {
|
||||
it("closes after a slow drag past a quarter of the height", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 140, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
// It leaves from where the finger let go, with the backdrop already lighter.
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)");
|
||||
expect(Number(s.overlay.style.opacity)).toBeCloseTo(1 - 140 / 400, 6);
|
||||
expect(s.controller.swallowClick()).toBe(true);
|
||||
expect(s.controller.swallowClick()).toBe(false);
|
||||
});
|
||||
|
||||
it("lets its own close through: the swallowed click comes after it", () => {
|
||||
const s = setup();
|
||||
let swallowedDuringClose: boolean | null = null;
|
||||
s.onClose.mockImplementation(() => {
|
||||
// The close presses a Radix Close button; a swallow armed already
|
||||
// would eat that click and the sheet would never close.
|
||||
swallowedDuringClose = s.controller.swallowClick();
|
||||
});
|
||||
s.drag(s.header, 200, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(swallowedDuringClose).toBe(false);
|
||||
expect(s.controller.swallowClick()).toBe(true);
|
||||
});
|
||||
|
||||
it("springs back after a short slow drag", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 60, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
expect(s.content.style.transition).toContain(`${SHEET_SETTLE_MS}ms`);
|
||||
expect(s.overlay.style.opacity).toBe("1");
|
||||
// Once the glide has run, the stylesheet owns the position again.
|
||||
s.timers.forEach((run) => run());
|
||||
expect(s.content.style.transform).toBe("");
|
||||
expect(s.content.style.transition).toBe("");
|
||||
expect(s.overlay.style.opacity).toBe("");
|
||||
});
|
||||
|
||||
it("closes on a fast flick even when short", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 60, { steps: 4, stepMs: 10 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes moves once per frame, never per event", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
for (let y = 110; y <= 200; y += 10) {
|
||||
s.controller.pointerMove(s.ev(s.header, 100, y));
|
||||
}
|
||||
expect(s.frames).toHaveLength(1);
|
||||
s.flushFrames();
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 100px, 0)");
|
||||
});
|
||||
|
||||
it("drags content down from its very top", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, 200, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves scrolled content to scroll", () => {
|
||||
const s = setup();
|
||||
s.body.scrollTop = 40;
|
||||
s.drag(s.row, 300, { stepMs: 40 });
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
expect(s.content.style.transform).toBeUndefined();
|
||||
expect(s.frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets an upward swipe on content scroll it", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, -200);
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onSnap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never counts a sideways gesture", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, 30, { dx: 200 });
|
||||
s.drag(s.header, 30, { dx: 200 });
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a slider or a field its own gesture", () => {
|
||||
const s = setup();
|
||||
const slider = fake({ parentElement: s.body, own: true });
|
||||
s.drag(slider, 300, { stepMs: 40 });
|
||||
expect(s.frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores the mouse", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 0, 0, { pointerType: "mouse" }));
|
||||
s.controller.pointerMove(s.ev(s.header, 0, 300, { pointerType: "mouse" }));
|
||||
s.controller.pointerUp(s.ev(s.header, 0, 300, { pointerType: "mouse" }));
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resists above the top", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 400));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 0));
|
||||
s.flushFrames();
|
||||
const y = Number(/(-?[\d.]+)px/.exec(s.content.style.transform)?.[1]);
|
||||
expect(y).toBeLessThan(0);
|
||||
expect(y).toBeGreaterThan(-24);
|
||||
});
|
||||
|
||||
it("puts the sheet back when a second finger lands", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 200));
|
||||
s.controller.pointerDown(
|
||||
s.ev(s.header, 50, 50, { pointerId: 2, isPrimary: false }),
|
||||
);
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
s.controller.pointerUp(s.ev(s.header, 100, 400));
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("touchmove", () => {
|
||||
const touch = (y: number, x = 100) => {
|
||||
const event = {
|
||||
touches: [{ clientX: x, clientY: y }],
|
||||
cancelable: true,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
return event;
|
||||
};
|
||||
|
||||
it("is cancelled under a drag, and on the chrome from the start", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
const first = touch(102);
|
||||
s.controller.touchMove(first);
|
||||
expect(first.preventDefault).toHaveBeenCalled();
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 150));
|
||||
const later = touch(150);
|
||||
s.controller.touchMove(later);
|
||||
expect(later.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is left alone for content that scrolls", () => {
|
||||
const s = setup();
|
||||
s.body.scrollTop = 10;
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const event = touch(160);
|
||||
s.controller.touchMove(event);
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is cancelled for a pull down on content at its top", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const down = touch(106);
|
||||
s.controller.touchMove(down);
|
||||
expect(down.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is left alone for an upward swipe on content", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const up = touch(80);
|
||||
s.controller.touchMove(up);
|
||||
expect(up.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a preview step", () => {
|
||||
it("rises to full height on a pull up from the content", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.row, -300, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onSnap).toHaveBeenCalledWith("full");
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
expect(s.footer.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
});
|
||||
|
||||
it("keeps the footer on the window edge while the sheet moves up", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 500));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 400));
|
||||
s.flushFrames();
|
||||
// Sheet at 350 - 100 = 250; the footer lifted by the same 250.
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 250px, 0)");
|
||||
expect(s.footer.style.transform).toBe("translate3d(0, -250px, 0)");
|
||||
expect(s.overlay.style.opacity).toBe("1");
|
||||
});
|
||||
|
||||
it("goes back to the preview step on a short pull down from full height", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.row, -300, { stepMs: 40, restMs: 200 });
|
||||
s.timers.forEach((run) => run());
|
||||
expect(s.getSnap()).toBe("full");
|
||||
s.drag(s.header, 250, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onSnap).toHaveBeenLastCalledWith("preview");
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes from the preview step past a quarter of what it shows", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.header, 130, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
AiConsentState,
|
||||
AiProviderState,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
|
||||
import {
|
||||
clientAbortMs,
|
||||
PROVIDER_DEFAULT_TIMEOUT_MS,
|
||||
} from "@/lib/ai/effective-timeout";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import type { AiUsageState } from "@/lib/ai/usage-level";
|
||||
|
||||
@@ -69,6 +72,18 @@ function useAiProviderStateInner(): AiProviderState {
|
||||
return user?.ai?.provider ?? NO_PROVIDER_STATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1267 — the timeout for a save of `PUT /api/coach/about-me`. A save that
|
||||
* changes the text or the shared sections waits on one model call (the
|
||||
* clarifying questions) before it answers, so the fixed 15 s request default
|
||||
* reported a failed save while a slow model was still within the person's
|
||||
* response timeout, and the server went on to commit it.
|
||||
*/
|
||||
export function useAboutMeSaveTimeoutMs(): number {
|
||||
const { responseTimeoutMs } = useAiProviderState();
|
||||
return clientAbortMs(responseTimeoutMs, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — the Coach's thinking depth, resolved on the server (`coachReasoning`
|
||||
* on `GET /api/auth/me`), or `null` while it is not known: `/me` loading, no
|
||||
|
||||
@@ -84,15 +84,21 @@ export function usePullToRefresh({
|
||||
setPullDistance(0);
|
||||
};
|
||||
|
||||
// A sheet or dialog is open: Radix's scroll lock marks the body while it
|
||||
// holds the page. A drag inside the sheet (down to close it, or along
|
||||
// its content) is the sheet's, never a pull on the page behind it; the
|
||||
// listeners sit on `window`, so without this they saw every one.
|
||||
const modalOpen = () => document.body.hasAttribute("data-scroll-locked");
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (busy || e.touches.length !== 1 || !atTop()) return;
|
||||
if (busy || e.touches.length !== 1 || !atTop() || modalOpen()) return;
|
||||
startY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (busy || startY === null) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy <= 0 || !atTop()) {
|
||||
if (dy <= 0 || !atTop() || modalOpen()) {
|
||||
// Upward drag or the page started scrolling — stand down quietly.
|
||||
if (pulling) reset();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
backdropOpacity,
|
||||
contentDrags,
|
||||
dragOffset,
|
||||
footerLift,
|
||||
lockAxis,
|
||||
releaseVelocity,
|
||||
settleSheet,
|
||||
translateYOf,
|
||||
type SheetSample,
|
||||
type SheetSnap,
|
||||
} from "@/lib/sheet-gesture";
|
||||
|
||||
/**
|
||||
* The bottom sheet's drag: the sheet follows a touch with `translateY`, the
|
||||
* backdrop fades with it, and on release the sheet closes or settles
|
||||
* (`settleSheet`). Moves are written straight to the elements once per
|
||||
* animation frame; React renders only when the resting place changes.
|
||||
*
|
||||
* Only touch and pen drag. A mouse, the keyboard and a screen reader close
|
||||
* the sheet through its X, Escape or the backdrop, which Radix owns, as it
|
||||
* owns focus and the scroll lock on the page behind.
|
||||
*/
|
||||
|
||||
/** The settle after a release, matched to the sheet's closing animation. */
|
||||
export const SHEET_SETTLE_MS = 220;
|
||||
const SETTLE_EASE = "cubic-bezier(0, 0, 0.2, 1)";
|
||||
|
||||
/** A touch on one of these is the control's own (typing, a slider). */
|
||||
const OWN_GESTURE =
|
||||
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"], [data-sheet-no-drag]';
|
||||
|
||||
type Mode = "pending" | "drag" | "ignore";
|
||||
|
||||
interface Gesture {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
/** The nearest vertically scrolling element under the touch, or null on the sheet's chrome (grip, header, footer). */
|
||||
scroller: HTMLElement | null;
|
||||
mode: Mode;
|
||||
base: number;
|
||||
height: number;
|
||||
previewOffset: number | null;
|
||||
footer: HTMLElement | null;
|
||||
offset: number;
|
||||
samples: SheetSample[];
|
||||
}
|
||||
|
||||
export interface SheetDragEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
isPrimary: boolean;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
target: EventTarget | null;
|
||||
}
|
||||
|
||||
export interface SheetTouchMoveEvent {
|
||||
touches: ArrayLike<{ clientX: number; clientY: number }>;
|
||||
cancelable: boolean;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
export interface SheetDragOptions {
|
||||
content: () => HTMLElement | null;
|
||||
overlay: () => HTMLElement | null;
|
||||
/** An element whose height is the preview height; null without a preview. */
|
||||
probe: () => HTMLElement | null;
|
||||
hasPreview: () => boolean;
|
||||
snap: () => SheetSnap;
|
||||
onSnap: (snap: SheetSnap) => void;
|
||||
onClose: () => void;
|
||||
now?: () => number;
|
||||
frame?: (callback: () => void) => number;
|
||||
cancelFrame?: (id: number) => void;
|
||||
later?: (callback: () => void, ms: number) => unknown;
|
||||
reducedMotion?: () => boolean;
|
||||
computedTransform?: (element: HTMLElement) => string;
|
||||
overflowY?: (element: HTMLElement) => string;
|
||||
}
|
||||
|
||||
export interface SheetDragController {
|
||||
pointerDown: (event: SheetDragEvent) => void;
|
||||
pointerMove: (event: SheetDragEvent) => void;
|
||||
pointerUp: (event: SheetDragEvent) => void;
|
||||
pointerCancel: (event: SheetDragEvent) => void;
|
||||
/** Non-passive `touchmove` on the sheet: stops the page and the content from scrolling under a drag. */
|
||||
touchMove: (event: SheetTouchMoveEvent) => void;
|
||||
/** True once for the click that ends a drag, which must not press what it ends on. */
|
||||
swallowClick: () => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function translate(y: number): string {
|
||||
return `translate3d(0, ${y}px, 0)`;
|
||||
}
|
||||
|
||||
export function createSheetDrag(
|
||||
options: SheetDragOptions,
|
||||
): SheetDragController {
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const frame =
|
||||
options.frame ?? ((callback) => requestAnimationFrame(() => callback()));
|
||||
const cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id));
|
||||
const later =
|
||||
options.later ?? ((callback, ms) => window.setTimeout(callback, ms));
|
||||
const reducedMotion =
|
||||
options.reducedMotion ??
|
||||
(() =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const computedTransform =
|
||||
options.computedTransform ??
|
||||
((element) => getComputedStyle(element).transform);
|
||||
const overflowY =
|
||||
options.overflowY ?? ((element) => getComputedStyle(element).overflowY);
|
||||
|
||||
let gesture: Gesture | null = null;
|
||||
let pendingFrame = 0;
|
||||
let swallow = false;
|
||||
let settleToken = 0;
|
||||
|
||||
function scrollerUnder(
|
||||
target: EventTarget | null,
|
||||
content: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
let node = target as HTMLElement | null;
|
||||
while (node) {
|
||||
const overflow = overflowY(node);
|
||||
if (
|
||||
(overflow === "auto" || overflow === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight + 1
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
if (node === content) return null;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function previewOffsetOf(height: number): number | null {
|
||||
if (!options.hasPreview()) return null;
|
||||
const probe = options.probe();
|
||||
if (!probe) return null;
|
||||
return Math.max(0, height - probe.offsetHeight);
|
||||
}
|
||||
|
||||
function paint() {
|
||||
pendingFrame = 0;
|
||||
const g = gesture;
|
||||
const content = options.content();
|
||||
if (!g || !content) return;
|
||||
content.style.transform = translate(g.offset);
|
||||
const overlay = options.overlay();
|
||||
const lowest = g.previewOffset ?? 0;
|
||||
if (overlay) {
|
||||
overlay.style.opacity = String(
|
||||
backdropOpacity(g.offset, lowest, g.height),
|
||||
);
|
||||
}
|
||||
if (g.footer && g.previewOffset !== null) {
|
||||
g.footer.style.transform = translate(
|
||||
-footerLift(g.offset, g.previewOffset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!pendingFrame) pendingFrame = frame(paint);
|
||||
}
|
||||
|
||||
function clearInline(content: HTMLElement, footer: HTMLElement | null) {
|
||||
content.style.transition = "";
|
||||
content.style.transform = "";
|
||||
const overlay = options.overlay();
|
||||
if (overlay) {
|
||||
overlay.style.transition = "";
|
||||
overlay.style.opacity = "";
|
||||
}
|
||||
if (footer) {
|
||||
footer.style.transition = "";
|
||||
footer.style.transform = "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Glide to a resting place, then hand the position back to the stylesheet. */
|
||||
function settleTo(g: Gesture, snap: SheetSnap) {
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
if (pendingFrame) {
|
||||
cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
}
|
||||
const target =
|
||||
snap === "preview" && g.previewOffset !== null ? g.previewOffset : 0;
|
||||
if (snap !== options.snap()) options.onSnap(snap);
|
||||
const token = ++settleToken;
|
||||
if (reducedMotion()) {
|
||||
clearInline(content, g.footer);
|
||||
return;
|
||||
}
|
||||
const transition = `${SHEET_SETTLE_MS}ms ${SETTLE_EASE}`;
|
||||
content.style.transition = `transform ${transition}`;
|
||||
content.style.transform = translate(target);
|
||||
const overlay = options.overlay();
|
||||
if (overlay) {
|
||||
overlay.style.transition = `opacity ${transition}`;
|
||||
overlay.style.opacity = "1";
|
||||
}
|
||||
if (g.footer && g.previewOffset !== null) {
|
||||
g.footer.style.transition = `transform ${transition}`;
|
||||
g.footer.style.transform = translate(
|
||||
-footerLift(target, g.previewOffset),
|
||||
);
|
||||
}
|
||||
const footer = g.footer;
|
||||
later(() => {
|
||||
if (token !== settleToken) return;
|
||||
clearInline(content, footer);
|
||||
}, SHEET_SETTLE_MS + 40);
|
||||
}
|
||||
|
||||
function beginDrag(g: Gesture, content: HTMLElement) {
|
||||
// The opening slide (a CSS animation) would hide a move written under
|
||||
// it; finish it, then start from wherever the sheet is right now, which
|
||||
// may be part-way through a settle.
|
||||
for (const animation of content.getAnimations?.() ?? []) {
|
||||
if ("animationName" in animation) animation.finish();
|
||||
}
|
||||
settleToken += 1;
|
||||
const base = translateYOf(computedTransform(content));
|
||||
content.style.transition = "none";
|
||||
content.style.transform = translate(base);
|
||||
const overlay = options.overlay();
|
||||
if (overlay) overlay.style.transition = "none";
|
||||
if (g.footer) g.footer.style.transition = "none";
|
||||
g.base = base;
|
||||
g.offset = base;
|
||||
g.mode = "drag";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
return {
|
||||
pointerDown(event) {
|
||||
swallow = false;
|
||||
if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
|
||||
if (!event.isPrimary) {
|
||||
// A second finger: whatever the first was doing, put the sheet back.
|
||||
if (gesture?.mode === "drag") settleTo(gesture, options.snap());
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
const target = event.target as Element | null;
|
||||
if (
|
||||
target &&
|
||||
typeof target.closest === "function" &&
|
||||
target.closest(OWN_GESTURE)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scroller = scrollerUnder(event.target, content);
|
||||
if (scroller && scroller.scrollTop > 0) return;
|
||||
const height = content.offsetHeight;
|
||||
gesture = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scroller,
|
||||
mode: "pending",
|
||||
base: 0,
|
||||
height,
|
||||
previewOffset: previewOffsetOf(height),
|
||||
footer: options.hasPreview()
|
||||
? content.querySelector<HTMLElement>("[data-sheet-footer]")
|
||||
: null,
|
||||
offset: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
|
||||
pointerMove(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId || g.mode === "ignore") return;
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
const dx = event.clientX - g.startX;
|
||||
const dy = event.clientY - g.startY;
|
||||
if (g.mode === "pending") {
|
||||
const axis = lockAxis(dx, dy);
|
||||
if (axis === null) return;
|
||||
if (axis === "x") {
|
||||
g.mode = "ignore";
|
||||
return;
|
||||
}
|
||||
if (
|
||||
g.scroller &&
|
||||
!contentDrags({
|
||||
scrollTop: g.scroller.scrollTop,
|
||||
dy,
|
||||
snap: options.snap(),
|
||||
hasPreview: g.previewOffset !== null,
|
||||
})
|
||||
) {
|
||||
g.mode = "ignore";
|
||||
return;
|
||||
}
|
||||
beginDrag(g, content);
|
||||
try {
|
||||
content.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// A pointer the browser no longer tracks; the moves still arrive.
|
||||
}
|
||||
}
|
||||
g.offset = dragOffset(g.base, dy);
|
||||
g.samples.push({ t: now(), y: g.offset });
|
||||
if (g.samples.length > 32) g.samples.splice(0, g.samples.length - 32);
|
||||
schedule();
|
||||
},
|
||||
|
||||
pointerUp(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId) return;
|
||||
if (g.mode !== "drag") {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const decision = settleSheet({
|
||||
offset: g.offset,
|
||||
velocity: releaseVelocity(g.samples, now()),
|
||||
height: g.height,
|
||||
previewOffset: g.previewOffset,
|
||||
});
|
||||
if (decision.kind === "close") {
|
||||
if (pendingFrame) {
|
||||
cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
}
|
||||
// The sheet leaves from where the finger let go: the closing
|
||||
// animation starts from this inline position and opacity.
|
||||
paint();
|
||||
reset();
|
||||
// Before the swallow is armed: the close is itself a click.
|
||||
options.onClose();
|
||||
swallow = true;
|
||||
return;
|
||||
}
|
||||
swallow = true;
|
||||
reset();
|
||||
settleTo(g, decision.snap);
|
||||
},
|
||||
|
||||
pointerCancel(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId) return;
|
||||
reset();
|
||||
if (g.mode === "drag") settleTo(g, options.snap());
|
||||
},
|
||||
|
||||
touchMove(event) {
|
||||
const g = gesture;
|
||||
if (!g || g.mode === "ignore") return;
|
||||
if (g.mode === "drag") {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Still deciding: the chrome never scrolls or rubber-bands the page,
|
||||
// and content at its top must not start a scroll the drag would lose.
|
||||
const touch = event.touches[0];
|
||||
if (!touch || !event.cancelable) return;
|
||||
if (!g.scroller) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - g.startX;
|
||||
const dy = touch.clientY - g.startY;
|
||||
if (
|
||||
Math.abs(dy) > Math.abs(dx) &&
|
||||
contentDrags({
|
||||
scrollTop: g.scroller.scrollTop,
|
||||
dy,
|
||||
snap: options.snap(),
|
||||
hasPreview: g.previewOffset !== null,
|
||||
})
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
swallowClick() {
|
||||
if (!swallow) return false;
|
||||
swallow = false;
|
||||
return true;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (pendingFrame) cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
settleToken += 1;
|
||||
gesture = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseSheetDragArgs {
|
||||
enabled: boolean;
|
||||
hasPreview: boolean;
|
||||
snap: SheetSnap;
|
||||
onSnap: (snap: SheetSnap) => void;
|
||||
onClose: () => void;
|
||||
contentRef: React.RefObject<HTMLElement | null>;
|
||||
overlayRef: React.RefObject<HTMLElement | null>;
|
||||
probeRef: React.RefObject<HTMLElement | null>;
|
||||
/**
|
||||
* The mounted sheet element, as state rather than a ref. The sheet is
|
||||
* portaled and mounts only while open, often long after the component
|
||||
* that owns it (the More sheet's content is rendered, closed, from the
|
||||
* first paint); the touch listeners below must be attached when the
|
||||
* element appears, which a ref cannot announce.
|
||||
*/
|
||||
node: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface SheetDragHandlers {
|
||||
onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerMove?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerUp?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerCancel?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onClickCapture?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export function useSheetDrag({
|
||||
enabled,
|
||||
hasPreview,
|
||||
snap,
|
||||
onSnap,
|
||||
onClose,
|
||||
contentRef,
|
||||
overlayRef,
|
||||
probeRef,
|
||||
node,
|
||||
}: UseSheetDragArgs): SheetDragHandlers {
|
||||
const latest = useRef({ hasPreview, snap, onSnap, onClose });
|
||||
useEffect(() => {
|
||||
latest.current = { hasPreview, snap, onSnap, onClose };
|
||||
});
|
||||
|
||||
// Made on first use, from an event or the effect, never during render.
|
||||
const controllerRef = useRef<SheetDragController | null>(null);
|
||||
const controller = useCallback((): SheetDragController => {
|
||||
controllerRef.current ??= createSheetDrag({
|
||||
content: () => contentRef.current,
|
||||
overlay: () => overlayRef.current,
|
||||
probe: () => probeRef.current,
|
||||
hasPreview: () => latest.current.hasPreview,
|
||||
snap: () => latest.current.snap,
|
||||
onSnap: (next) => latest.current.onSnap(next),
|
||||
onClose: () => latest.current.onClose(),
|
||||
});
|
||||
return controllerRef.current;
|
||||
}, [contentRef, overlayRef, probeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !node) return;
|
||||
const content = node;
|
||||
const overlay = overlayRef.current;
|
||||
const drag = controller();
|
||||
const onContentTouch = (event: TouchEvent) => drag.touchMove(event);
|
||||
// A touch on the backdrop never scrolls or pulls the page behind it.
|
||||
const onOverlayTouch = (event: TouchEvent) => {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
};
|
||||
content.addEventListener("touchmove", onContentTouch, { passive: false });
|
||||
overlay?.addEventListener("touchmove", onOverlayTouch, { passive: false });
|
||||
return () => {
|
||||
content.removeEventListener("touchmove", onContentTouch);
|
||||
overlay?.removeEventListener("touchmove", onOverlayTouch);
|
||||
drag.dispose();
|
||||
};
|
||||
}, [enabled, node, controller, overlayRef]);
|
||||
|
||||
if (!enabled) return {};
|
||||
return {
|
||||
onPointerDown: (event) => controller().pointerDown(event),
|
||||
onPointerMove: (event) => controller().pointerMove(event),
|
||||
onPointerUp: (event) => controller().pointerUp(event),
|
||||
onPointerCancel: (event) => controller().pointerCancel(event),
|
||||
onClickCapture: (event) => {
|
||||
if (controller().swallowClick()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* #1267 — the pinned dispatchers leave the deadline to the caller's signal.
|
||||
*
|
||||
* undici's Agent arms its own 300 s header timer and 300 s body timer by
|
||||
* default. A local model with the response timeout at 600 s answers a
|
||||
* non-streamed completion only once it is done, so its headers can arrive
|
||||
* after 300 s: undici then dropped a call the caller still allowed, and the
|
||||
* failure read as a bare network error. Waiting five minutes in a unit test
|
||||
* proves nothing faster, so this reads the options the Agent was built with.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "undici";
|
||||
import {
|
||||
getPinnedOperatorApprovedDispatcher,
|
||||
getPinnedPublicDispatcher,
|
||||
_resetPinnedDispatcherForTests,
|
||||
} from "../safe-fetch-dispatcher";
|
||||
|
||||
function agentOptions(agent: Agent): {
|
||||
headersTimeout?: number;
|
||||
bodyTimeout?: number;
|
||||
} {
|
||||
const key = Object.getOwnPropertySymbols(agent).find(
|
||||
(s) => s.description === "options",
|
||||
);
|
||||
expect(key).toBeDefined();
|
||||
return (agent as unknown as Record<symbol, Record<string, number>>)[key!];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_resetPinnedDispatcherForTests();
|
||||
});
|
||||
|
||||
describe("pinned dispatcher timers", () => {
|
||||
it.each([
|
||||
["public", getPinnedPublicDispatcher],
|
||||
["operator-approved", getPinnedOperatorApprovedDispatcher],
|
||||
])("the %s dispatcher arms no header or body timer of its own", (_, get) => {
|
||||
const options = agentOptions(get());
|
||||
expect(options.headersTimeout).toBe(0);
|
||||
expect(options.bodyTimeout).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHEET_AXIS_SLOP,
|
||||
SHEET_OVERDRAG,
|
||||
backdropOpacity,
|
||||
contentDrags,
|
||||
dragOffset,
|
||||
footerLift,
|
||||
lockAxis,
|
||||
releaseVelocity,
|
||||
rubberBand,
|
||||
settleSheet,
|
||||
translateYOf,
|
||||
} from "../sheet-gesture";
|
||||
|
||||
describe("lockAxis", () => {
|
||||
it("waits inside the slop", () => {
|
||||
expect(lockAxis(0, 0)).toBeNull();
|
||||
expect(lockAxis(SHEET_AXIS_SLOP - 1, -(SHEET_AXIS_SLOP - 1))).toBeNull();
|
||||
});
|
||||
|
||||
it("reads vertical and sideways once past it", () => {
|
||||
expect(lockAxis(2, SHEET_AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(2, -20)).toBe("y");
|
||||
expect(lockAxis(SHEET_AXIS_SLOP, 3)).toBe("x");
|
||||
expect(lockAxis(-30, 12)).toBe("x");
|
||||
});
|
||||
|
||||
it("gives a diagonal tie to the sideways gesture", () => {
|
||||
expect(lockAxis(12, 12)).toBe("x");
|
||||
expect(lockAxis(-12, 12)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rubberBand / dragOffset", () => {
|
||||
it("follows the finger 1:1 below the top", () => {
|
||||
expect(dragOffset(0, 120)).toBe(120);
|
||||
expect(dragOffset(300, -200)).toBe(100);
|
||||
expect(dragOffset(300, -300)).toBe(0);
|
||||
});
|
||||
|
||||
it("resists above the top and never passes the limit", () => {
|
||||
const small = -dragOffset(0, -4);
|
||||
expect(small).toBeGreaterThan(3);
|
||||
expect(small).toBeLessThan(4);
|
||||
const far = -dragOffset(0, -2000);
|
||||
expect(far).toBeLessThan(SHEET_OVERDRAG);
|
||||
expect(far).toBeGreaterThan(SHEET_OVERDRAG * 0.95);
|
||||
// From a lowered resting place the resistance starts at the top only.
|
||||
expect(dragOffset(300, -350)).toBeCloseTo(-rubberBand(50), 6);
|
||||
});
|
||||
|
||||
it("is monotonic", () => {
|
||||
let previous = Infinity;
|
||||
for (let d = 0; d <= 500; d += 25) {
|
||||
const y = dragOffset(0, -d);
|
||||
expect(y).toBeLessThanOrEqual(previous);
|
||||
previous = y;
|
||||
}
|
||||
});
|
||||
|
||||
it("has no travel for no distance", () => {
|
||||
expect(rubberBand(0)).toBe(0);
|
||||
expect(rubberBand(-5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseVelocity", () => {
|
||||
it("measures px/ms over the samples inside the window", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 0 },
|
||||
{ t: 50, y: 10 },
|
||||
{ t: 100, y: 60 },
|
||||
{ t: 150, y: 110 },
|
||||
];
|
||||
// Window 100 ms before t=150 keeps t=50..150: (110-10)/100.
|
||||
expect(releaseVelocity(samples, 150)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it("is zero when the finger rested before letting go", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 0 },
|
||||
{ t: 40, y: 200 },
|
||||
];
|
||||
expect(releaseVelocity(samples, 400)).toBe(0);
|
||||
});
|
||||
|
||||
it("is zero with a single sample", () => {
|
||||
expect(releaseVelocity([{ t: 10, y: 5 }], 20)).toBe(0);
|
||||
expect(releaseVelocity([], 20)).toBe(0);
|
||||
});
|
||||
|
||||
it("is negative for an upward flick", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 100 },
|
||||
{ t: 40, y: 20 },
|
||||
];
|
||||
expect(releaseVelocity(samples, 40)).toBeCloseTo(-2, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleSheet without a preview step", () => {
|
||||
const base = { height: 400, previewOffset: null };
|
||||
|
||||
it("closes past a quarter of the height", () => {
|
||||
expect(settleSheet({ ...base, offset: 101, velocity: 0 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
});
|
||||
|
||||
it("springs back short of it", () => {
|
||||
expect(settleSheet({ ...base, offset: 99, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on a flick down faster than 0.5 px/ms", () => {
|
||||
expect(settleSheet({ ...base, offset: 30, velocity: 0.6 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 30, velocity: 0.5 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not close on a flick down from above the top", () => {
|
||||
expect(settleSheet({ ...base, offset: -10, velocity: 2 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleSheet with a preview step", () => {
|
||||
// 800 px tall, the preview shows 450: resting offset 350.
|
||||
const base = { height: 800, previewOffset: 350 };
|
||||
|
||||
it("closes a quarter of the visible preview below it", () => {
|
||||
expect(settleSheet({ ...base, offset: 350 + 113, velocity: 0 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 350 + 112, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("goes to the nearer step on a slow release", () => {
|
||||
expect(settleSheet({ ...base, offset: 100, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 250, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("rises to the top on a flick up", () => {
|
||||
expect(settleSheet({ ...base, offset: 330, velocity: -0.8 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a full sheet to the preview step on a flick down, and closes below it", () => {
|
||||
expect(settleSheet({ ...base, offset: 60, velocity: 1.2 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 360, velocity: 1.2 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdropOpacity", () => {
|
||||
it("is whole at and above the lowest resting place", () => {
|
||||
expect(backdropOpacity(0, 0, 400)).toBe(1);
|
||||
expect(backdropOpacity(-20, 0, 400)).toBe(1);
|
||||
expect(backdropOpacity(100, 300, 800)).toBe(1);
|
||||
});
|
||||
|
||||
it("fades in proportion to the way out", () => {
|
||||
expect(backdropOpacity(200, 0, 400)).toBeCloseTo(0.5, 6);
|
||||
expect(backdropOpacity(300 + 250, 300, 800)).toBeCloseTo(0.5, 6);
|
||||
expect(backdropOpacity(900, 0, 400)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerLift", () => {
|
||||
it("holds the footer on the window edge between the steps", () => {
|
||||
expect(footerLift(0, 350)).toBe(0);
|
||||
expect(footerLift(200, 350)).toBe(200);
|
||||
expect(footerLift(350, 350)).toBe(350);
|
||||
});
|
||||
|
||||
it("lets it leave with the sheet below the preview step", () => {
|
||||
expect(footerLift(500, 350)).toBe(350);
|
||||
expect(footerLift(-10, 350)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDrags", () => {
|
||||
const top = { scrollTop: 0, hasPreview: false, snap: "full" as const };
|
||||
|
||||
it("scrolls first when the content is scrolled", () => {
|
||||
expect(contentDrags({ ...top, scrollTop: 1, dy: 30 })).toBe(false);
|
||||
expect(contentDrags({ ...top, scrollTop: 120, dy: -30 })).toBe(false);
|
||||
});
|
||||
|
||||
it("drags down from the very top", () => {
|
||||
expect(contentDrags({ ...top, dy: 30 })).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an upward swipe scroll, unless it raises a preview sheet", () => {
|
||||
expect(contentDrags({ ...top, dy: -30 })).toBe(false);
|
||||
expect(
|
||||
contentDrags({ ...top, hasPreview: true, snap: "full", dy: -30 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
contentDrags({ ...top, hasPreview: true, snap: "preview", dy: -30 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateYOf", () => {
|
||||
it("reads 2D and 3D matrices", () => {
|
||||
expect(translateYOf("none")).toBe(0);
|
||||
expect(translateYOf("")).toBe(0);
|
||||
expect(translateYOf("matrix(1, 0, 0, 1, 0, 123.5)")).toBe(123.5);
|
||||
expect(
|
||||
translateYOf("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -7, 0, 1)"),
|
||||
).toBe(-7);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveEffectiveTimeoutMs } from "../effective-timeout";
|
||||
import {
|
||||
CLIENT_ABORT_MARGIN_MS,
|
||||
clientAbortMs,
|
||||
PROVIDER_DEFAULT_TIMEOUT_MS,
|
||||
resolveEffectiveTimeoutMs,
|
||||
} from "../effective-timeout";
|
||||
|
||||
const BUDGET = 120_000;
|
||||
|
||||
@@ -24,3 +29,28 @@ describe("resolveEffectiveTimeoutMs", () => {
|
||||
expect(resolveEffectiveTimeoutMs(45, 60_000)).toBe(45_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientAbortMs (#1267)", () => {
|
||||
it("waits the published setting per call plus the margin", () => {
|
||||
expect(clientAbortMs(600_000, 2)).toBe(
|
||||
2 * 600_000 + CLIENT_ABORT_MARGIN_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("never waits less per call than the route's own longer default", () => {
|
||||
// 60 s is what the payload publishes when nothing is set; the briefing
|
||||
// route gives each call 180 s then.
|
||||
expect(clientAbortMs(60_000, 2, 180_000)).toBe(
|
||||
2 * 180_000 + CLIENT_ABORT_MARGIN_MS,
|
||||
);
|
||||
expect(clientAbortMs(600_000, 2, 180_000)).toBe(
|
||||
2 * 600_000 + CLIENT_ABORT_MARGIN_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a missing value as the provider default", () => {
|
||||
expect(clientAbortMs(undefined, 1)).toBe(
|
||||
PROVIDER_DEFAULT_TIMEOUT_MS + CLIENT_ABORT_MARGIN_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* #1267 — `providerFailureKind` against the errors the real request path
|
||||
* throws: the local client, `safeFetch`, the pinned operator-approved
|
||||
* dispatcher and a loopback server that misbehaves on purpose. Every case
|
||||
* used to log as `cause: "network"`.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
import { providerFailureKind } from "../failure-kind";
|
||||
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
|
||||
import { singleUserTurn } from "../types";
|
||||
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
|
||||
|
||||
type Behaviour =
|
||||
| "slow"
|
||||
| "drop-mid-body"
|
||||
| "not-json"
|
||||
| "http-500"
|
||||
| "empty"
|
||||
| "reasoning-only";
|
||||
|
||||
let behaviour: Behaviour = "slow";
|
||||
let server: http.Server;
|
||||
let origin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
switch (behaviour) {
|
||||
case "slow":
|
||||
setTimeout(() => res.end("{}"), 1_000);
|
||||
return;
|
||||
case "drop-mid-body":
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.write('{"choices":[{"message":{"content":"half');
|
||||
setTimeout(() => res.socket?.destroy(), 20);
|
||||
return;
|
||||
case "not-json":
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("<html>proxy error</html>");
|
||||
return;
|
||||
case "http-500":
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end('{"error":"boom"}');
|
||||
return;
|
||||
case "empty":
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: "" }, finish_reason: "stop" }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "reasoning-only":
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: { content: "", reasoning: "thinking…" },
|
||||
finish_reason: "length",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
_resetPinnedDispatcherForTests();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetReasoningDialectCache();
|
||||
});
|
||||
|
||||
async function failureFor(
|
||||
target: string,
|
||||
opts: { timeoutMs?: number; signal?: AbortSignal; effort?: "none" } = {},
|
||||
) {
|
||||
const saved = process.env.AI_PRIVATE_ORIGINS;
|
||||
process.env.AI_PRIVATE_ORIGINS = new URL(target).origin;
|
||||
try {
|
||||
const c = new LocalOpenAICompatibleClient({
|
||||
baseUrl: `${target}/v1`,
|
||||
model: "m",
|
||||
});
|
||||
if (opts.effort) c.reasoningEffort = opts.effort;
|
||||
const err = await c
|
||||
.generateCompletion({
|
||||
...singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
timeoutMs: opts.timeoutMs ?? 5_000,
|
||||
}),
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
})
|
||||
.then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).not.toBeNull();
|
||||
return providerFailureKind(err);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.AI_PRIVATE_ORIGINS;
|
||||
else process.env.AI_PRIVATE_ORIGINS = saved;
|
||||
}
|
||||
}
|
||||
|
||||
async function closedPortOrigin(): Promise<string> {
|
||||
const probe = net.createServer();
|
||||
await new Promise<void>((r) => probe.listen(0, "127.0.0.1", r));
|
||||
const port = (probe.address() as AddressInfo).port;
|
||||
await new Promise<void>((r) => probe.close(() => r()));
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
describe("providerFailureKind on the real request path", () => {
|
||||
it("names our own deadline a timeout", async () => {
|
||||
behaviour = "slow";
|
||||
expect(await failureFor(origin, { timeoutMs: 150 })).toEqual({
|
||||
failure: "timeout",
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("names a caller cancel an abort, not a timeout", async () => {
|
||||
behaviour = "slow";
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
expect(
|
||||
(await failureFor(origin, { signal: controller.signal })).failure,
|
||||
).toBe("aborted");
|
||||
});
|
||||
|
||||
it("names a refused connection with its code", async () => {
|
||||
expect(await failureFor(await closedPortOrigin())).toEqual({
|
||||
failure: "refused",
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
});
|
||||
|
||||
it("names a connection dropped mid-body a socket failure", async () => {
|
||||
behaviour = "drop-mid-body";
|
||||
const out = await failureFor(origin);
|
||||
expect(out.failure).toBe("socket");
|
||||
expect(out.code).toMatch(/^UND_ERR_/);
|
||||
});
|
||||
|
||||
it("names a 200 that is not JSON a parse failure", async () => {
|
||||
behaviour = "not-json";
|
||||
expect((await failureFor(origin)).failure).toBe("parse");
|
||||
});
|
||||
|
||||
it("names a non-2xx status http", async () => {
|
||||
behaviour = "http-500";
|
||||
expect((await failureFor(origin)).failure).toBe("http");
|
||||
});
|
||||
|
||||
it("tells an empty answer from one that only reasoned", async () => {
|
||||
behaviour = "empty";
|
||||
expect((await failureFor(origin)).failure).toBe("empty_response");
|
||||
// Already asked with `none`: nothing left to retry, the reply is final.
|
||||
behaviour = "reasoning-only";
|
||||
expect((await failureFor(origin, { effort: "none" })).failure).toBe(
|
||||
"reasoning_only",
|
||||
);
|
||||
});
|
||||
|
||||
it("never lets a free-form string through as a code", () => {
|
||||
const err = Object.assign(new Error("x"), {
|
||||
code: "see http://user:pass@host/",
|
||||
});
|
||||
expect(providerFailureKind(err)).toEqual({
|
||||
failure: "unknown",
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* The local provider against an OpenAI-compatible server that answers the way
|
||||
* Ollama serves Gemma 4 on `/v1/chat/completions`, driven through the real
|
||||
* client, the real `safeFetch`, the pinned operator-approved dispatcher and a
|
||||
* real HTTP server on loopback. Nothing on the request path is mocked; only
|
||||
* the chain's health ledger and the signal sink are swapped for in-memory
|
||||
* stand-ins, because they write to Postgres and to the log transport.
|
||||
*
|
||||
* Ollama routes a thinking model's output into a separate `reasoning` field.
|
||||
* When Gemma 4 thinks (any `reasoning_effort` other than `none`) a long
|
||||
* generation can come back with `content: ""` — the thought used the whole
|
||||
* token budget (`finish_reason: "length"`), or the answer itself landed in
|
||||
* `reasoning` (`finish_reason: "stop"`). Asked with `reasoning_effort: "none"`
|
||||
* the same model answers in `content`. The background briefing always asks to
|
||||
* reason, so before the fix every briefing on such a model failed with a
|
||||
* sentinel status 0 that the chain logged as `cause: "network"` and the
|
||||
* read path showed as a timeout — in the very second the server logged 200.
|
||||
*
|
||||
* The transport block at the top rules the transport out: a slow, chunked,
|
||||
* large, multi-byte, kept-alive response arrives intact through the same
|
||||
* pinned path, so the failure is not in the bytes but in what the body says.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const emitSignal = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/logging/signal", () => ({
|
||||
emitSignal: (...a: unknown[]) => emitSignal(...a),
|
||||
}));
|
||||
|
||||
vi.mock("../provider-health-ledger", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../provider-health-ledger")
|
||||
>("../provider-health-ledger");
|
||||
return {
|
||||
...actual,
|
||||
postgresProviderHealthLedger: {
|
||||
async getSkipHints() {
|
||||
return new Map();
|
||||
},
|
||||
async recordSuccess() {},
|
||||
async recordFailure() {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
LocalOpenAICompatibleClient,
|
||||
resetLocalJsonDialectCache,
|
||||
} from "../local-client";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
clearLastWorkingProviderCache,
|
||||
runRawCompletionWithFallback,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
} from "../provider-runner";
|
||||
import { singleUserTurn, type CompletionParams } from "../types";
|
||||
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
|
||||
|
||||
const BRIEFING_JSON = JSON.stringify({
|
||||
dailyBriefing: { headline: "Steady week", body: "Nothing stood out." },
|
||||
});
|
||||
|
||||
type GemmaMode =
|
||||
/** Thinking spends the whole budget: empty content, `length`. */
|
||||
| "budget"
|
||||
/** The answer lands in `reasoning`, content empty, `stop`. */
|
||||
| "misrouted"
|
||||
/** Empty content however it is asked. */
|
||||
| "always-empty";
|
||||
|
||||
interface SeenRequest {
|
||||
reasoningEffort: unknown;
|
||||
stream: boolean;
|
||||
responseFormat: unknown;
|
||||
}
|
||||
|
||||
let server: http.Server;
|
||||
let origin: string;
|
||||
let mode: GemmaMode = "budget";
|
||||
let headerDelayMs = 0;
|
||||
let seen: SeenRequest[] = [];
|
||||
/** When set, the answer `content` carries this instead of the briefing. */
|
||||
let bigAnswer: string | null = null;
|
||||
|
||||
/** A long thought, with multi-byte characters, the way Gemma writes one. */
|
||||
const THOUGHT =
|
||||
"Let me look at the week's readings — Blutdruck, Gewicht… ".repeat(3200);
|
||||
|
||||
function ollamaEnvelope(message: Record<string, unknown>, finish: string) {
|
||||
return {
|
||||
id: "chatcmpl-417",
|
||||
object: "chat.completion",
|
||||
created: 1_760_000_000,
|
||||
model: "gemma4:12b",
|
||||
system_fingerprint: "fp_ollama",
|
||||
choices: [{ index: 0, message, finish_reason: finish }],
|
||||
usage: {
|
||||
prompt_tokens: 5200,
|
||||
completion_tokens: 6120,
|
||||
total_tokens: 11320,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gemmaReply(reasoningEffort: unknown) {
|
||||
const thinks = reasoningEffort !== "none";
|
||||
if (mode === "always-empty" || (thinks && mode === "budget")) {
|
||||
return ollamaEnvelope(
|
||||
{ role: "assistant", content: "", reasoning: THOUGHT },
|
||||
"length",
|
||||
);
|
||||
}
|
||||
if (thinks && mode === "misrouted") {
|
||||
return ollamaEnvelope(
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: `${THOUGHT}\n${BRIEFING_JSON}`,
|
||||
},
|
||||
"stop",
|
||||
);
|
||||
}
|
||||
return ollamaEnvelope(
|
||||
{ role: "assistant", content: bigAnswer ?? BRIEFING_JSON },
|
||||
"stop",
|
||||
);
|
||||
}
|
||||
|
||||
/** Write `text` as several chunks, splitting inside multi-byte characters. */
|
||||
async function writeChunked(res: http.ServerResponse, text: string) {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
const step = 4093; // odd on purpose: cuts through multi-byte sequences
|
||||
for (let i = 0; i < bytes.length; i += step) {
|
||||
res.write(bytes.subarray(i, i + step));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", async () => {
|
||||
const body = JSON.parse(raw) as Record<string, unknown>;
|
||||
seen.push({
|
||||
reasoningEffort: body.reasoning_effort,
|
||||
stream: body.stream === true,
|
||||
responseFormat: body.response_format,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, headerDelayMs));
|
||||
const reply = gemmaReply(body.reasoning_effort);
|
||||
if (body.stream === true) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
const message = reply.choices[0].message as {
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
};
|
||||
const frames: string[] = [];
|
||||
if (message.reasoning) {
|
||||
frames.push(
|
||||
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning: message.reasoning } }] })}\n\n`,
|
||||
);
|
||||
}
|
||||
if (message.content) {
|
||||
frames.push(
|
||||
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: message.content } }] })}\n\n`,
|
||||
);
|
||||
}
|
||||
frames.push(
|
||||
`data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: reply.choices[0].finish_reason }] })}\n\n`,
|
||||
"data: [DONE]\n\n",
|
||||
);
|
||||
await writeChunked(res, frames.join(""));
|
||||
return;
|
||||
}
|
||||
// Go's net/http answers a body this size chunked, without a length.
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
await writeChunked(res, JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
_resetPinnedDispatcherForTests();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
const savedOrigins = process.env.AI_PRIVATE_ORIGINS;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AI_PRIVATE_ORIGINS = origin;
|
||||
mode = "budget";
|
||||
headerDelayMs = 0;
|
||||
bigAnswer = null;
|
||||
seen = [];
|
||||
emitSignal.mockClear();
|
||||
clearLastWorkingProviderCache();
|
||||
resetLocalJsonDialectCache();
|
||||
resetReasoningDialectCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedOrigins === undefined) delete process.env.AI_PRIVATE_ORIGINS;
|
||||
else process.env.AI_PRIVATE_ORIGINS = savedOrigins;
|
||||
});
|
||||
|
||||
function client() {
|
||||
return new LocalOpenAICompatibleClient({
|
||||
baseUrl: `${origin}/v1`,
|
||||
model: "gemma4:12b",
|
||||
});
|
||||
}
|
||||
|
||||
/** The briefing call exactly as `runBriefingCompletion` shapes it. */
|
||||
function briefingParams(effort: "low" | "medium" = "medium"): CompletionParams {
|
||||
return {
|
||||
...singleUserTurn({
|
||||
system: "You write the daily briefing.",
|
||||
user: "Snapshot: …",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2500,
|
||||
timeoutMs: 30_000,
|
||||
responseFormat: "json",
|
||||
}),
|
||||
reasoning: { effort, summaries: false },
|
||||
};
|
||||
}
|
||||
|
||||
function runChain(params: CompletionParams) {
|
||||
return runRawCompletionWithFallback({
|
||||
userId: "user-1",
|
||||
surface: "job",
|
||||
providers: [{ providerType: "local", instance: client() }],
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
function linkFailedMeta(): Array<Record<string, unknown>> {
|
||||
return emitSignal.mock.calls
|
||||
.map((c) => c[0] as { action: string; meta: Record<string, unknown> })
|
||||
.filter((s) => s.action === "ai.chain.link_failed")
|
||||
.map((s) => s.meta);
|
||||
}
|
||||
|
||||
describe("transport: the pinned path delivers a slow, large, chunked reply intact", () => {
|
||||
it("returns a delayed 180 KB multi-byte answer whole, twice in a row", async () => {
|
||||
bigAnswer = JSON.stringify({ text: THOUGHT });
|
||||
headerDelayMs = 300;
|
||||
const c = client();
|
||||
// The entry's own reasoning setting at "Off": the server answers in
|
||||
// `content`, which is what this block wants to read back byte for byte.
|
||||
c.reasoningEffort = "none";
|
||||
const params = singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
maxTokens: 50,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const first = await c.generateCompletion(params);
|
||||
const second = await c.generateCompletion(params);
|
||||
expect(Buffer.byteLength(bigAnswer, "utf8")).toBeGreaterThan(90_000);
|
||||
expect(first.content).toBe(bigAnswer);
|
||||
expect(second.content).toBe(bigAnswer);
|
||||
expect(seen).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemma 4 on Ollama: a reply with reasoning and no answer", () => {
|
||||
it("answers the briefing when thinking spent the budget (finish_reason length)", async () => {
|
||||
mode = "budget";
|
||||
const outcome = await runChain(briefingParams("medium"));
|
||||
expect(outcome.result.content).toBe(BRIEFING_JSON);
|
||||
// The first ask reasons; the repeat asks the model not to.
|
||||
expect(seen.map((s) => s.reasoningEffort)).toEqual(["medium", "none"]);
|
||||
// The JSON mode stays on for the repeat.
|
||||
expect(seen[1].responseFormat).toEqual({ type: "json_object" });
|
||||
expect(linkFailedMeta()).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers the briefing when the answer landed in `reasoning` (finish_reason stop)", async () => {
|
||||
mode = "misrouted";
|
||||
const outcome = await runChain(briefingParams("low"));
|
||||
expect(outcome.result.content).toBe(BRIEFING_JSON);
|
||||
expect(seen.map((s) => s.reasoningEffort)).toEqual(["low", "none"]);
|
||||
});
|
||||
|
||||
it("remembers the model answers only without reasoning, so the next call asks once", async () => {
|
||||
mode = "budget";
|
||||
await runChain(briefingParams("medium"));
|
||||
seen = [];
|
||||
const again = await runChain(briefingParams("medium"));
|
||||
expect(again.result.content).toBe(BRIEFING_JSON);
|
||||
expect(seen.map((s) => s.reasoningEffort)).toEqual(["none"]);
|
||||
});
|
||||
|
||||
it("does the same on the streaming path the Coach uses", async () => {
|
||||
mode = "budget";
|
||||
const deltas: string[] = [];
|
||||
const outcome = await runStreamingRawCompletionWithFallback({
|
||||
userId: "user-1",
|
||||
surface: "coach",
|
||||
providers: [{ providerType: "local", instance: client() }],
|
||||
params: briefingParams("medium"),
|
||||
onDelta: (d: string) => deltas.push(d),
|
||||
});
|
||||
expect(outcome.result.content).toBe(BRIEFING_JSON);
|
||||
expect(deltas.join("")).toBe(BRIEFING_JSON);
|
||||
expect(seen.map((s) => [s.stream, s.reasoningEffort])).toEqual([
|
||||
[true, "medium"],
|
||||
[true, "none"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names an empty answer as one, not as a network failure, when the repeat is empty too", async () => {
|
||||
mode = "always-empty";
|
||||
const err = await runChain(briefingParams("medium")).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(AllProvidersFailedError);
|
||||
const [meta] = linkFailedMeta();
|
||||
expect(meta).toMatchObject({
|
||||
provider: "local",
|
||||
status: 0,
|
||||
cause: "empty_response",
|
||||
failure: "reasoning_only",
|
||||
code: null,
|
||||
surface: "job",
|
||||
});
|
||||
expect((err as AllProvidersFailedError).attempts[0].failureKind).toBe(
|
||||
"reasoning_only",
|
||||
);
|
||||
});
|
||||
|
||||
it("does the same through the OpenAI-compatible gateway provider", async () => {
|
||||
mode = "misrouted";
|
||||
const gateway = new OpenAIClient({
|
||||
apiKey: "",
|
||||
model: "gemma4:12b",
|
||||
baseUrl: `${origin}/v1`,
|
||||
providerType: "openai-compatible",
|
||||
});
|
||||
const outcome = await runRawCompletionWithFallback({
|
||||
userId: "user-1",
|
||||
surface: "job",
|
||||
providers: [{ providerType: "openai-compatible", instance: gateway }],
|
||||
params: briefingParams("medium"),
|
||||
});
|
||||
expect(outcome.result.content).toBe(BRIEFING_JSON);
|
||||
expect(seen.map((s) => s.reasoningEffort)).toEqual(["medium", "none"]);
|
||||
});
|
||||
});
|
||||
@@ -78,14 +78,22 @@ export const CLIENT_ABORT_MARGIN_MS = 30_000;
|
||||
* account payload published. Never recomputes the setting: an absent or
|
||||
* malformed value (a payload from before the field existed) reads as the
|
||||
* server's own default.
|
||||
*
|
||||
* `surfaceDefaultMs` is the route's own per-call default when it is longer
|
||||
* than the provider default (the briefing's 180 s). The published value
|
||||
* cannot say whether the person set it or it is the 60 s default, so the
|
||||
* browser waits for the longer of the two: it may wait longer than the
|
||||
* server needs, never shorter (#1267).
|
||||
*/
|
||||
export function clientAbortMs(
|
||||
responseTimeoutMs: number | null | undefined,
|
||||
modelCalls: number,
|
||||
surfaceDefaultMs?: number,
|
||||
): number {
|
||||
const perCall =
|
||||
const published =
|
||||
typeof responseTimeoutMs === "number" && responseTimeoutMs > 0
|
||||
? responseTimeoutMs
|
||||
: PROVIDER_DEFAULT_TIMEOUT_MS;
|
||||
const perCall = Math.max(published, surfaceDefaultMs ?? 0);
|
||||
return perCall * modelCalls + CLIENT_ABORT_MARGIN_MS;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
/**
|
||||
* What one failed provider call actually ran into, in our own closed
|
||||
* vocabulary. The chain used to collapse every failure without an HTTP status
|
||||
* into "network", so a refused connection, our own deadline, a socket the
|
||||
* server closed and a 200 that carried no answer all read the same in the log
|
||||
* and in the briefing's empty state (#1267).
|
||||
*
|
||||
* Read from the thrown value only: the `SafeFetchError` kind, the client's own
|
||||
* `kind` tag, and the Node / undici error `code` somewhere down the `cause`
|
||||
* chain. Never the message, which can carry a URL.
|
||||
*/
|
||||
export type ProviderFailureKind =
|
||||
/** Our own deadline (the response-timeout setting or the surface's). */
|
||||
| "timeout"
|
||||
/** The caller cancelled (the Coach tab closed, a job shut down). */
|
||||
| "aborted"
|
||||
/** The destination policy refused the host or its resolved address. */
|
||||
| "private_host"
|
||||
| "dns"
|
||||
| "refused"
|
||||
| "unreachable"
|
||||
| "connect_timeout"
|
||||
/** The connection was reset or closed under a request in flight. */
|
||||
| "reset"
|
||||
| "socket"
|
||||
| "headers_timeout"
|
||||
| "body_timeout"
|
||||
| "tls"
|
||||
/** A 2xx body that was not the JSON it claimed to be. */
|
||||
| "parse"
|
||||
/** A 2xx reply with no answer and no reasoning. */
|
||||
| "empty_response"
|
||||
/** A 2xx reply that reasoned and never answered. */
|
||||
| "reasoning_only"
|
||||
/** A stream that broke before its first token. */
|
||||
| "stream_aborted"
|
||||
/** A 2xx body that carried the gateway's own failure. */
|
||||
| "embedded_error"
|
||||
/** A non-2xx HTTP status. */
|
||||
| "http"
|
||||
/** A transport failure with no code this list knows. */
|
||||
| "network"
|
||||
| "unknown";
|
||||
|
||||
export interface ProviderFailure {
|
||||
failure: ProviderFailureKind;
|
||||
/**
|
||||
* The Node / undici error code that decided `failure`, when there is one
|
||||
* (`ECONNRESET`, `UND_ERR_SOCKET`). Only strings shaped like a code pass, so
|
||||
* nothing free-form reaches a log line.
|
||||
*/
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{1,47}$/;
|
||||
|
||||
const CODE_KINDS: ReadonlyArray<[RegExp, ProviderFailureKind]> = [
|
||||
[/^(ENOTFOUND|EAI_AGAIN|EAI_NONAME|EAI_FAIL)$/, "dns"],
|
||||
[/^ECONNREFUSED$/, "refused"],
|
||||
[/^(EHOSTUNREACH|ENETUNREACH|EHOSTDOWN|ENETDOWN)$/, "unreachable"],
|
||||
[/^(UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT)$/, "connect_timeout"],
|
||||
[/^(ECONNRESET|EPIPE|ECONNABORTED)$/, "reset"],
|
||||
[/^(UND_ERR_SOCKET|UND_ERR_CLOSED)$/, "socket"],
|
||||
[/^UND_ERR_HEADERS_TIMEOUT$/, "headers_timeout"],
|
||||
[/^UND_ERR_BODY_TIMEOUT$/, "body_timeout"],
|
||||
[
|
||||
/^(ERR_TLS_|ERR_SSL_|CERT_|DEPTH_ZERO_|SELF_SIGNED_|UNABLE_TO_|ERR_OSSL_)/,
|
||||
"tls",
|
||||
],
|
||||
];
|
||||
|
||||
const CLIENT_KINDS: ReadonlySet<ProviderFailureKind> = new Set([
|
||||
"empty_response",
|
||||
"reasoning_only",
|
||||
"stream_aborted",
|
||||
"embedded_error",
|
||||
]);
|
||||
|
||||
/** Every value down the `cause` chain (and an aggregate's `errors`). */
|
||||
function causeChain(error: unknown): unknown[] {
|
||||
const out: unknown[] = [];
|
||||
const queue: unknown[] = [error];
|
||||
while (queue.length > 0 && out.length < 16) {
|
||||
const next = queue.shift();
|
||||
if (next === null || typeof next !== "object" || out.includes(next)) {
|
||||
continue;
|
||||
}
|
||||
out.push(next);
|
||||
const value = next as { cause?: unknown; errors?: unknown };
|
||||
if (value.cause !== undefined) queue.push(value.cause);
|
||||
if (Array.isArray(value.errors)) queue.push(...value.errors);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function firstCode(chain: unknown[]): string | null {
|
||||
for (const value of chain) {
|
||||
const code = (value as { code?: unknown }).code;
|
||||
if (typeof code === "string" && CODE_SHAPE.test(code)) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNamed(value: unknown, name: string): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
(value as { name?: unknown }).name === name
|
||||
);
|
||||
}
|
||||
|
||||
/** Classify one thrown provider failure. Never throws. */
|
||||
export function providerFailureKind(error: unknown): ProviderFailure {
|
||||
try {
|
||||
const chain = causeChain(error);
|
||||
const code = firstCode(chain);
|
||||
const head = (error ?? {}) as { kind?: unknown; httpStatus?: unknown };
|
||||
|
||||
if (typeof head.kind === "string" && CLIENT_KINDS.has(head.kind as never)) {
|
||||
return { failure: head.kind as ProviderFailureKind, code };
|
||||
}
|
||||
if (typeof head.httpStatus === "number" && head.httpStatus > 0) {
|
||||
return { failure: "http", code: null };
|
||||
}
|
||||
if (error instanceof SafeFetchError && error.kind === "private_host") {
|
||||
return { failure: "private_host", code: null };
|
||||
}
|
||||
// Our deadline aborts with `TimeoutError`, a caller with `AbortError`;
|
||||
// `safeFetch` wraps both, a body read after the headers does not.
|
||||
if (chain.some((v) => isNamed(v, "TimeoutError"))) {
|
||||
return { failure: "timeout", code: null };
|
||||
}
|
||||
if (chain.some((v) => isNamed(v, "AbortError"))) {
|
||||
return { failure: "aborted", code: null };
|
||||
}
|
||||
if (code) {
|
||||
for (const [pattern, kind] of CODE_KINDS) {
|
||||
if (pattern.test(code)) return { failure: kind, code };
|
||||
}
|
||||
}
|
||||
if (chain.some((v) => v instanceof SyntaxError)) {
|
||||
return { failure: "parse", code };
|
||||
}
|
||||
if (error instanceof SafeFetchError || code !== null) {
|
||||
return { failure: "network", code };
|
||||
}
|
||||
if (chain.some((v) => v instanceof TypeError)) {
|
||||
return { failure: "network", code };
|
||||
}
|
||||
return { failure: "unknown", code };
|
||||
} catch {
|
||||
return { failure: "unknown", code: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the kinds that mean a deadline ran out. */
|
||||
export function isTimeoutFailure(kind: ProviderFailureKind | null): boolean {
|
||||
return (
|
||||
kind === "timeout" ||
|
||||
kind === "connect_timeout" ||
|
||||
kind === "headers_timeout" ||
|
||||
kind === "body_timeout"
|
||||
);
|
||||
}
|
||||
+127
-33
@@ -25,7 +25,9 @@ import {
|
||||
type JsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import {
|
||||
ANSWERS_ONLY_UNREASONED,
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
@@ -150,6 +152,25 @@ class ReasoningCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty-reply error. `httpStatus: 0` is the sentinel the chain classifier
|
||||
* treats as a hard failure; `kind` says whether the reply held reasoning and
|
||||
* no answer, or nothing at all, so the log names it instead of "network".
|
||||
*/
|
||||
function emptyReplyError(hadReasoning: boolean): Error {
|
||||
const err = new Error(
|
||||
hadReasoning
|
||||
? "Local AI returned reasoning but no answer"
|
||||
: "Local AI returned empty content",
|
||||
);
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: hadReasoning ? "reasoning_only" : "empty_response",
|
||||
upstream: "local",
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
/** The reasoning text a non-streamed message carries, if any. */
|
||||
function messageReasoning(message: unknown): string | null {
|
||||
const m = (message ?? {}) as Record<string, unknown>;
|
||||
@@ -230,10 +251,14 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
);
|
||||
|
||||
const callEffort = this.callReasoningEffort(params);
|
||||
const sentEffort = this.sentReasoningEffort(params);
|
||||
// Reasoning tokens count against `max_tokens` on these servers; the
|
||||
// thinking budget rides on top of the answer budget (a ceiling only).
|
||||
const thinkingTokens =
|
||||
params.reasoning && callEffort && params.reasoning.effort !== "off"
|
||||
params.reasoning &&
|
||||
sentEffort &&
|
||||
sentEffort !== "none" &&
|
||||
params.reasoning.effort !== "off"
|
||||
? REASONING_THINKING_BUDGET[params.reasoning.effort]
|
||||
: 0;
|
||||
const body = JSON.stringify({
|
||||
@@ -249,7 +274,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// body of a setup that never touched it stays byte-identical; "Off" is
|
||||
// `none`, which stops a thinking model spending the answer budget.
|
||||
// v1.41 — a call that carries its own resolved level wins over it.
|
||||
...(!params.reasoning && this.reasoningEffort
|
||||
// #1267 — once a model answered nothing while reasoning, the entry's
|
||||
// setting gives way to the `none` that `callReasoningEffort` sends.
|
||||
...(!params.reasoning && this.reasoningEffort && callEffort === null
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
...(callEffort ? { reasoning_effort: callEffort } : {}),
|
||||
@@ -288,13 +315,66 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
* call without one: the entry's #1126 setting applies, exactly as before.
|
||||
*/
|
||||
private callReasoningEffort(params: CompletionParams): string | null {
|
||||
const learned = learnedReasoningDialect(this.reasoningDialectKey());
|
||||
// #1267 — this endpoint and model answered a reasoning request with
|
||||
// nothing but reasoning: every call asks with `none` until the lesson
|
||||
// expires, a call without a level of its own included.
|
||||
if (learned === ANSWERS_ONLY_UNREASONED) return "none";
|
||||
if (!params.reasoning) return null;
|
||||
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
|
||||
return null;
|
||||
}
|
||||
if (learned === "none") return null;
|
||||
return params.reasoning.effort === "off" ? "none" : params.reasoning.effort;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `reasoning_effort` this request carries, whichever level decided it:
|
||||
* the call's own (`callReasoningEffort`), else the entry's #1126 setting.
|
||||
*/
|
||||
private sentReasoningEffort(params: CompletionParams): string | null {
|
||||
return (
|
||||
this.callReasoningEffort(params) ??
|
||||
(params.reasoning ? null : this.reasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1267 — a reply with reasoning and no answer. Ollama serves thinking
|
||||
* models (Gemma 4 among them) on `/v1/chat/completions` with the thought in
|
||||
* a separate `reasoning` field, and a long thought can leave `content`
|
||||
* empty: the thought used the whole token budget, or the answer itself was
|
||||
* parsed into `reasoning`. The same model asked with `reasoning_effort:
|
||||
* "none"` answers in `content`. So when a request that did not already say
|
||||
* `none` comes back empty with reasoning (or after asking to reason), learn
|
||||
* that this endpoint and model answer only unreasoned, and tell the caller
|
||||
* to ask once more. Returns false when the empty reply is final.
|
||||
*/
|
||||
private learnAnswerlessReasoning(
|
||||
params: CompletionParams,
|
||||
hadReasoning: boolean,
|
||||
finishReason: string | undefined,
|
||||
): boolean {
|
||||
const sent = this.sentReasoningEffort(params);
|
||||
if (sent === "none") return false;
|
||||
if (!hadReasoning && sent === null) return false;
|
||||
rememberReasoningDialect(
|
||||
this.reasoningDialectKey(),
|
||||
ANSWERS_ONLY_UNREASONED,
|
||||
);
|
||||
annotateReasoningDowngrade("local", sent ?? "default", "none");
|
||||
emitSignal({
|
||||
action: "ai.local.reasoning_answerless",
|
||||
level: "warn",
|
||||
meta: {
|
||||
effort: sent ?? "default",
|
||||
finish:
|
||||
finishReason === "length" || finishReason === "stop"
|
||||
? finishReason
|
||||
: "other",
|
||||
reasoning: hadReasoning,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
params: CompletionParams,
|
||||
): Promise<CompletionResult> {
|
||||
@@ -442,21 +522,25 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
content = split.content;
|
||||
}
|
||||
}
|
||||
const reasoning = collector.result(
|
||||
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
|
||||
);
|
||||
const finish = json.choices?.[0]?.finish_reason;
|
||||
if (!content) {
|
||||
const hadReasoning =
|
||||
messageReasoning(json.choices?.[0]?.message) !== null;
|
||||
if (this.learnAnswerlessReasoning(params, hadReasoning, finish)) {
|
||||
return this.generateCompletion(params);
|
||||
}
|
||||
// The reasoning that did arrive still reaches `onReasoning`, as before.
|
||||
collector.result(
|
||||
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
|
||||
);
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
||||
// an empty 200-OK reply apart from a transport failure. Cascade unchanged
|
||||
// (`status <= 0` is already a hard failure).
|
||||
const err = new Error("Local AI returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "local",
|
||||
});
|
||||
throw err;
|
||||
throw emptyReplyError(hadReasoning);
|
||||
}
|
||||
const reasoning = collector.result(
|
||||
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
content,
|
||||
@@ -470,7 +554,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
providerType: "local",
|
||||
// v1.28.28 (#470) — surface why the model stopped so the JSON callers
|
||||
// can tell a token-ceiling truncation ("length") from bad output.
|
||||
finishReason: mapFinishReason(json.choices?.[0]?.finish_reason),
|
||||
finishReason: mapFinishReason(finish),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
};
|
||||
}
|
||||
@@ -573,16 +657,22 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
buffered = split.content;
|
||||
}
|
||||
}
|
||||
const reasoning = collector.result(null);
|
||||
if (!buffered) {
|
||||
const err = new Error("Local AI returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "local",
|
||||
});
|
||||
throw err;
|
||||
const hadReasoning =
|
||||
messageReasoning(json?.choices?.[0]?.message) !== null;
|
||||
if (
|
||||
this.learnAnswerlessReasoning(
|
||||
params,
|
||||
hadReasoning,
|
||||
json?.choices?.[0]?.finish_reason,
|
||||
)
|
||||
) {
|
||||
return this.generateCompletionStream(params, onDelta);
|
||||
}
|
||||
collector.result(null);
|
||||
throw emptyReplyError(hadReasoning);
|
||||
}
|
||||
const reasoning = collector.result(null);
|
||||
onDelta(buffered);
|
||||
return {
|
||||
content: buffered,
|
||||
@@ -654,6 +744,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// carries. Remember the first one; it only matters if the stream ends
|
||||
// without a single token, in which case it is the honest reason.
|
||||
let streamedError: unknown = null;
|
||||
// #1267 — whether any reasoning arrived, asked for or not.
|
||||
let streamedReasoning = false;
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const armIdle = () => {
|
||||
@@ -707,6 +799,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
const choiceDelta = chunk.choices?.[0]?.delta;
|
||||
const reasoningDelta =
|
||||
choiceDelta?.reasoning_content ?? choiceDelta?.reasoning;
|
||||
if (typeof reasoningDelta === "string" && reasoningDelta.trim()) {
|
||||
streamedReasoning = true;
|
||||
}
|
||||
if (params.reasoning && typeof reasoningDelta === "string") {
|
||||
collector.add(reasoningDelta);
|
||||
}
|
||||
@@ -754,8 +849,6 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
collector.add(pending);
|
||||
}
|
||||
pending = "";
|
||||
const reasoning = collector.result(reasoningTokens);
|
||||
|
||||
if (!content) {
|
||||
// A stream that ended with a failure frame and no token reports the
|
||||
// failure, not "empty content".
|
||||
@@ -764,14 +857,15 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
label: "local AI endpoint",
|
||||
model: this.config.model,
|
||||
});
|
||||
const err = new Error("Local AI returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "local",
|
||||
});
|
||||
throw err;
|
||||
// An unclosed inline `<think>` block is reasoning too.
|
||||
const hadReasoning = streamedReasoning || endState === "inside";
|
||||
if (this.learnAnswerlessReasoning(params, hadReasoning, finishReason)) {
|
||||
return this.generateCompletionStream(params, onDelta);
|
||||
}
|
||||
collector.result(reasoningTokens);
|
||||
throw emptyReplyError(hadReasoning);
|
||||
}
|
||||
const reasoning = collector.result(reasoningTokens);
|
||||
|
||||
return {
|
||||
content,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type OpenAIChatCompletionsCapabilities,
|
||||
} from "./openai-capabilities";
|
||||
import {
|
||||
ANSWERS_ONLY_UNREASONED,
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
@@ -193,6 +194,10 @@ export class OpenAIClient implements AIProvider {
|
||||
this.config.model,
|
||||
);
|
||||
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
|
||||
// #1126 — the gateway entry's own setting, for a call without a resolved
|
||||
// level (sent below unless the wire already carries `none`).
|
||||
const entryEffort =
|
||||
!params.reasoning && this.isGateway ? this.reasoningEffort : null;
|
||||
// Reasoning tokens count against the completion budget on every one of
|
||||
// these wires, so the thinking budget rides on top of the answer budget
|
||||
// (a ceiling, not a charge). Without it a 600-token Coach round spends
|
||||
@@ -248,7 +253,12 @@ export class OpenAIClient implements AIProvider {
|
||||
// #1126 — the gateway entry's reasoning setting; Default sends
|
||||
// nothing. `api.openai.com` and Codex never get it. v1.41 — a call
|
||||
// that carries its own resolved level (`params.reasoning`) wins.
|
||||
...(!params.reasoning && this.isGateway && this.reasoningEffort
|
||||
// #1267 — once a model answered nothing while reasoning, the wire
|
||||
// below carries `none` instead (`planReasoning`).
|
||||
...(!params.reasoning &&
|
||||
this.isGateway &&
|
||||
this.reasoningEffort &&
|
||||
reasoningWire.kind === "none"
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
...(reasoningWire.kind === "reasoning_effort"
|
||||
@@ -423,6 +433,41 @@ export class OpenAIClient implements AIProvider {
|
||||
// A reply with tool calls and no prose is valid (F1 tool loop); only an
|
||||
// empty reply with neither content NOR tool calls is an error.
|
||||
if (!content && !toolCalls) {
|
||||
// #1267 — a gateway in front of a thinking model (Ollama serving
|
||||
// Gemma 4, say) can answer with the thought in `reasoning` and no
|
||||
// content. Asked with `none`, the same model answers; learn that once
|
||||
// and ask again. Same rule as the Local client.
|
||||
const sentEffort =
|
||||
reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort;
|
||||
const message = (choice?.message ?? {}) as Record<string, unknown>;
|
||||
const hadReasoning = [message.reasoning, message.reasoning_content].some(
|
||||
(v) => typeof v === "string" && v.trim().length > 0,
|
||||
);
|
||||
if (
|
||||
this.isGateway &&
|
||||
sentEffort !== "none" &&
|
||||
(hadReasoning || sentEffort !== null)
|
||||
) {
|
||||
rememberReasoningDialect(
|
||||
this.reasoningDialectKey(),
|
||||
ANSWERS_ONLY_UNREASONED,
|
||||
);
|
||||
annotateReasoningDowngrade(this.type, sentEffort ?? "default", "none");
|
||||
emitSignal({
|
||||
action: "ai.gateway.reasoning_answerless",
|
||||
level: "warn",
|
||||
meta: {
|
||||
effort: sentEffort ?? "default",
|
||||
finish:
|
||||
choice?.finish_reason === "length" ||
|
||||
choice?.finish_reason === "stop"
|
||||
? choice.finish_reason
|
||||
: "other",
|
||||
reasoning: hadReasoning,
|
||||
},
|
||||
});
|
||||
return this.generateCompletion(params);
|
||||
}
|
||||
// v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` +
|
||||
// `kind` so the provider-chain classifier can tell an empty 200-OK reply
|
||||
// apart from a genuine transport failure (ECONNRESET, DNS, timeout) in
|
||||
@@ -434,7 +479,7 @@ export class OpenAIClient implements AIProvider {
|
||||
);
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
kind: hadReasoning ? "reasoning_only" : "empty_response",
|
||||
upstream: this.isGateway ? "openai-compatible" : "openai",
|
||||
});
|
||||
throw err;
|
||||
@@ -529,10 +574,16 @@ export class OpenAIClient implements AIProvider {
|
||||
capabilities: OpenAIChatCompletionsCapabilities,
|
||||
): OpenAIReasoningWire {
|
||||
const reasoning = params.reasoning;
|
||||
if (!reasoning) return { kind: "none" };
|
||||
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
|
||||
return { kind: "none" };
|
||||
const learned = learnedReasoningDialect(this.reasoningDialectKey());
|
||||
// #1267 — a gateway model that answered a reasoning request with
|
||||
// reasoning alone is asked with `none`, with or without a call level.
|
||||
if (this.isGateway && learned === ANSWERS_ONLY_UNREASONED) {
|
||||
return isOpenRouterEndpoint(this.config.baseUrl)
|
||||
? { kind: "openrouter", effort: "none" }
|
||||
: { kind: "reasoning_effort", effort: "none" };
|
||||
}
|
||||
if (!reasoning) return { kind: "none" };
|
||||
if (learned === "none") return { kind: "none" };
|
||||
const level = reasoning.effort;
|
||||
if (this.isGateway) {
|
||||
const effort = level === "off" ? "none" : level;
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from "./provider-health-ledger";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import {
|
||||
isTimeoutFailure,
|
||||
providerFailureKind,
|
||||
type ProviderFailure,
|
||||
type ProviderFailureKind,
|
||||
} from "./failure-kind";
|
||||
import {
|
||||
isOperatorFundedProvider,
|
||||
readDailySpend,
|
||||
@@ -174,6 +180,11 @@ export interface FallbackHop {
|
||||
attempt: number;
|
||||
failureReason: string;
|
||||
httpStatus: number | null;
|
||||
/**
|
||||
* #1267 — what the call ran into (`failure-kind.ts`). Absent on a hop that
|
||||
* never called the provider (the operator cost cap).
|
||||
*/
|
||||
failureKind?: ProviderFailureKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,6 +415,7 @@ function hopCause(
|
||||
error: unknown,
|
||||
status: number | null,
|
||||
bodyClass: string | null,
|
||||
failure: ProviderFailureKind,
|
||||
): string {
|
||||
const err = error as {
|
||||
credentialExpired?: unknown;
|
||||
@@ -414,14 +426,22 @@ function hopCause(
|
||||
if (bodyClass?.startsWith("classified:")) {
|
||||
return bodyClass.slice("classified:".length);
|
||||
}
|
||||
if (status === null || status <= 0) return "network";
|
||||
if (status === null || status <= 0) {
|
||||
// #1267 — a 200 without an answer is not a transport failure, and only a
|
||||
// deadline is a timeout. `failure` on the same line says which one.
|
||||
if (failure === "empty_response" || failure === "reasoning_only") {
|
||||
return "empty_response";
|
||||
}
|
||||
if (isTimeoutFailure(failure)) return "timeout";
|
||||
return "network";
|
||||
}
|
||||
if (status === 401 || status === 403) return "auth_rejected";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status >= 500) return "upstream_5xx";
|
||||
return "upstream_4xx";
|
||||
}
|
||||
|
||||
interface HopSignal {
|
||||
interface HopSignal extends ProviderFailure {
|
||||
provider: ProviderChainType;
|
||||
attempt: number;
|
||||
status: number | null;
|
||||
@@ -450,6 +470,9 @@ function signalFailedHops(
|
||||
attempt: hop.attempt,
|
||||
status: hop.status,
|
||||
cause: hop.cause,
|
||||
// #1267 — closed vocabulary plus a code-shaped string, never a message.
|
||||
failure: hop.failure,
|
||||
code: hop.code,
|
||||
recovered,
|
||||
surface,
|
||||
},
|
||||
@@ -584,11 +607,18 @@ async function runRawChain(
|
||||
throw error;
|
||||
}
|
||||
const summary = summariseError(error);
|
||||
const failure = providerFailureKind(error);
|
||||
failedHops.push({
|
||||
provider: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
status: summary.status,
|
||||
cause: hopCause(error, summary.status, summary.bodyExcerpt),
|
||||
cause: hopCause(
|
||||
error,
|
||||
summary.status,
|
||||
summary.bodyExcerpt,
|
||||
failure.failure,
|
||||
),
|
||||
...failure,
|
||||
});
|
||||
void ledger.recordFailure(userId, candidate.providerType, summary.status);
|
||||
const hop: FallbackHop = {
|
||||
@@ -596,6 +626,7 @@ async function runRawChain(
|
||||
attempt: i + 1,
|
||||
failureReason: summary.reason,
|
||||
httpStatus: summary.status,
|
||||
failureKind: failure.failure,
|
||||
};
|
||||
hops.push(hop);
|
||||
annotate({
|
||||
@@ -604,6 +635,7 @@ async function runRawChain(
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
[`ai_chain_hop_${i + 1}_body`]: summary.bodyExcerpt,
|
||||
[`ai_chain_hop_${i + 1}_failure`]: failure.failure,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ export interface ReasoningDialectKey {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1267 — the learned value for an endpoint and model that answered a
|
||||
* reasoning request with reasoning alone (Ollama's Gemma 4 on
|
||||
* `/v1/chat/completions`): ask them with `reasoning_effort: "none"`. Shared by
|
||||
* the Local and the OpenAI-compatible gateway clients. Distinct from
|
||||
* `"none"`, which both clients read as "send no reasoning field at all".
|
||||
*/
|
||||
export const ANSWERS_ONLY_UNREASONED = "off";
|
||||
|
||||
/** How long a learned downgrade holds before the full request is tried again. */
|
||||
export const DIALECT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -186,3 +186,61 @@ export function getBodyFatTargetRange(gender: string | null | undefined): {
|
||||
// Gender-neutral fallback: midpoint of male/female fitness+acceptable.
|
||||
return { min: 17, max: 27 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart bands for oxygen saturation (%).
|
||||
*
|
||||
* Without a personal target the chart keeps its clinical shading: below 90 %
|
||||
* the hypoxia floor, 90 to 95 % the watch zone, 95 to 100 % normal. With one
|
||||
* (a COPD or high-altitude user living at 88 to 92 %, say) the green band is
|
||||
* the person's own and the wings come from the resolved range, so the zone
|
||||
* agrees with the `/targets` card. Saturation cannot exceed 100 %, so nothing
|
||||
* is shaded above it; the floor stays at 80 % unless the person's own wing
|
||||
* reaches lower.
|
||||
*/
|
||||
export function buildOxygenSaturationBands(
|
||||
ownRange: TrafficRange | null,
|
||||
): ValueBand[] {
|
||||
if (!ownRange) {
|
||||
return [
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
];
|
||||
}
|
||||
const floor = Math.min(80, ownRange.orangeMin);
|
||||
const ceiling = 100;
|
||||
const bands: ValueBand[] = [
|
||||
{
|
||||
min: floor,
|
||||
max: ownRange.orangeMin,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: ownRange.orangeMin,
|
||||
max: ownRange.greenMin,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: ownRange.greenMin,
|
||||
max: Math.min(ownRange.greenMax, ceiling),
|
||||
color: "var(--success)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: Math.min(ownRange.greenMax, ceiling),
|
||||
max: Math.min(ownRange.orangeMax, ceiling),
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: Math.min(ownRange.orangeMax, ceiling),
|
||||
max: ceiling,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
];
|
||||
return bands.filter((band) => band.max > band.min);
|
||||
}
|
||||
@@ -1269,6 +1269,30 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
|
||||
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
|
||||
});
|
||||
|
||||
it("an own blood-pressure, pulse and body-fat target reach the dashboard bands", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
PULSE: { min: 48, max: 58 },
|
||||
BODY_FAT: { min: 12, max: 16 },
|
||||
},
|
||||
}),
|
||||
{ modules: () => Promise.resolve(moduleMap()) },
|
||||
);
|
||||
expect(snap.targetBands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
expect(snap.targetBands.pulseDisplayRange.greenMin).toBe(48);
|
||||
expect(snap.targetBands.pulseDisplayRange.greenMax).toBe(58);
|
||||
expect(snap.targetBands.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
});
|
||||
|
||||
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
modules: () => Promise.resolve(moduleMap({ sleep: false })),
|
||||
|
||||
@@ -105,28 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: Date | null;
|
||||
gender: "MALE" | "FEMALE" | null;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}> = [
|
||||
{
|
||||
name: "male 180cm with DOB",
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
{
|
||||
name: "female 165cm with DOB",
|
||||
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
|
||||
gender: "FEMALE",
|
||||
heightCm: 165,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
{
|
||||
name: "no-profile user (null DOB / gender / height)",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -146,20 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "OTHER",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
const unanswered = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(other).toEqual(unanswered);
|
||||
const male = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(bands.bpTargets).toBeNull();
|
||||
expect(bands.bpSysRange).toBeNull();
|
||||
@@ -198,14 +198,14 @@ describe("buildTargetBands — user weight target", () => {
|
||||
it("replaces the height-derived band with the user's own", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
|
||||
});
|
||||
expect(withTarget.weightRange?.greenMin).toBe(74);
|
||||
expect(withTarget.weightRange?.greenMax).toBe(78);
|
||||
|
||||
const heightDerived = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
|
||||
// The chart bands follow the same range, so tile and chart agree.
|
||||
@@ -221,7 +221,7 @@ describe("buildTargetBands — user weight target", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: { min: 60, max: 65 },
|
||||
thresholdsJson: { WEIGHT: { min: 60, max: 65 } },
|
||||
});
|
||||
expect(bands.weightRange?.greenMin).toBe(60);
|
||||
expect(bands.weightBands?.length).toBeGreaterThan(0);
|
||||
@@ -230,14 +230,132 @@ describe("buildTargetBands — user weight target", () => {
|
||||
it("leaves every non-weight band untouched", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
|
||||
});
|
||||
const without = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(withTarget.bpTargets).toEqual(without.bpTargets);
|
||||
expect(withTarget.pulseBands).toEqual(without.pulseBands);
|
||||
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other metrics the dashboard shades take the person's own target the
|
||||
* same way weight does. A blood-pressure, resting-pulse or body-fat target set
|
||||
* on `/targets` used to leave the chart on the age / sex default, so the band
|
||||
* under the line contradicted the band the person had just entered.
|
||||
*/
|
||||
describe("buildTargetBands — the user's own targets on every shaded metric", () => {
|
||||
const profile = {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE" as const,
|
||||
heightCm: 180,
|
||||
};
|
||||
const without = buildTargetBands({ ...profile, thresholdsJson: null });
|
||||
const greenOf = (bands: Array<{ min: number; max: number; color: string }>) =>
|
||||
bands.find((band) => band.color === "var(--success)");
|
||||
|
||||
it("draws the blood-pressure zones and tile ranges from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
},
|
||||
});
|
||||
expect(bands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
expect(bands.bpTargets).not.toEqual(without.bpTargets);
|
||||
expect(bands.bpSysRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 115, greenMax: 125 }),
|
||||
);
|
||||
expect(bands.bpDiaRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 70, greenMax: 78 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fills a one-sided blood-pressure target with the age default", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 110, max: 128 } },
|
||||
});
|
||||
expect(bands.bpTargets?.sysLow).toBe(110);
|
||||
expect(bands.bpTargets?.sysHigh).toBe(128);
|
||||
expect(bands.bpTargets?.diaLow).toBe(without.bpTargets?.diaLow);
|
||||
expect(bands.bpTargets?.diaHigh).toBe(without.bpTargets?.diaHigh);
|
||||
expect(bands.bpDiaRange).toEqual(without.bpDiaRange);
|
||||
});
|
||||
|
||||
it("draws a blood-pressure band from a target even without a date of birth", () => {
|
||||
const bands = buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
},
|
||||
});
|
||||
expect(bands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the resting-pulse bands and display range from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { PULSE: { min: 48, max: 58 } },
|
||||
});
|
||||
expect(bands.pulseDisplayRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 48, greenMax: 58 }),
|
||||
);
|
||||
expect(greenOf(bands.pulseBands)).toEqual(
|
||||
expect.objectContaining({ min: 48, max: 58 }),
|
||||
);
|
||||
expect(bands.pulseBands).not.toEqual(without.pulseBands);
|
||||
});
|
||||
|
||||
it("draws the body-fat bands and range from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BODY_FAT: { min: 12, max: 16 } },
|
||||
});
|
||||
expect(bands.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
expect(greenOf(bands.bodyFatBands)).toEqual(
|
||||
expect.objectContaining({ min: 12, max: 16 }),
|
||||
);
|
||||
expect(bands.bodyFatBands).not.toEqual(without.bodyFatBands);
|
||||
});
|
||||
|
||||
it("ignores a malformed target and keeps the default band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
|
||||
PULSE: { min: "fast", max: 60 },
|
||||
BODY_FAT: null,
|
||||
},
|
||||
});
|
||||
expect(bands).toEqual(without);
|
||||
});
|
||||
|
||||
it("leaves the other metrics' bands alone", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
});
|
||||
expect(bands.pulseBands).toEqual(without.pulseBands);
|
||||
expect(bands.bodyFatBands).toEqual(without.bodyFatBands);
|
||||
expect(bands.weightBands).toEqual(without.weightBands);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const DELEGATE_PROFILE = {
|
||||
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
|
||||
gender: "FEMALE" as const,
|
||||
heightCm: 165,
|
||||
weightTargetOverride: { min: 55, max: 62 },
|
||||
thresholdsJson: { WEIGHT: { min: 55, max: 62 } },
|
||||
};
|
||||
|
||||
describe("viewerBandProfile", () => {
|
||||
@@ -45,7 +45,7 @@ describe("viewerBandProfile", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+86
-93
@@ -15,7 +15,7 @@
|
||||
* so the band/target math lives in exactly one place. `buildTargetBands`
|
||||
* is kept as an alias for the snapshot-era import sites.
|
||||
*/
|
||||
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildBandsFromTrafficRange,
|
||||
buildTrafficLightBands,
|
||||
@@ -30,16 +30,27 @@ import {
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
|
||||
import { getEffectiveRange } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
resolveEffectiveBpTargets,
|
||||
resolveThresholdOverride,
|
||||
type ThresholdMetric,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
|
||||
export interface DashboardTargetBands {
|
||||
/** Personalised BP target numbers (null when no DOB). */
|
||||
/**
|
||||
* BP target numbers: the user's own band where they set one, else the age
|
||||
* default. Null when neither exists (no target and no DOB).
|
||||
*/
|
||||
bpTargets: BpTargets | null;
|
||||
/** Systolic traffic range (null when no DOB). */
|
||||
/** Systolic traffic range, same precedence (null without target or DOB). */
|
||||
bpSysRange: TrafficRange | null;
|
||||
/** Diastolic traffic range (null when no DOB). */
|
||||
/** Diastolic traffic range, same precedence (null without target or DOB). */
|
||||
bpDiaRange: TrafficRange | null;
|
||||
/** Resting-pulse display range (always present — AHA fallback). */
|
||||
/**
|
||||
* Resting-pulse display range: the user's own target, else the
|
||||
* age / sex percentile band (AHA fallback). Always present.
|
||||
*/
|
||||
pulseDisplayRange: {
|
||||
greenMin: number;
|
||||
greenMax: number;
|
||||
@@ -48,7 +59,7 @@ export interface DashboardTargetBands {
|
||||
};
|
||||
/** Resting-pulse chart bands (always present — AHA fallback). */
|
||||
pulseBands: ValueBand[];
|
||||
/** Body-fat target range (gender-aware; always present). */
|
||||
/** Body-fat target range: own target, else gender-aware. Always present. */
|
||||
bodyFatRange: { min: number; max: number };
|
||||
/** Body-fat chart bands (always present). */
|
||||
bodyFatBands: ValueBand[];
|
||||
@@ -66,110 +77,92 @@ export interface DashboardTargetBands {
|
||||
* DB read. The snapshot builder and the page fallback both call this so
|
||||
* they produce byte-identical numbers.
|
||||
*
|
||||
* v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in
|
||||
* `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the
|
||||
* height-derived WHO band on both the weight range and the weight chart bands:
|
||||
* a user who has answered "this is my target" must not keep reading a shaded
|
||||
* zone that ignores the answer. `null` keeps the height-derived band. Required
|
||||
* rather than optional so every call site is named by the compiler.
|
||||
* `thresholdsJson` is the user's own targets blob as stored in
|
||||
* `User.thresholdsJson` (`null` when there is none). Every metric the dashboard
|
||||
* shades and that accepts a personal target reads it: weight, blood pressure,
|
||||
* resting pulse and body fat. A target the person set replaces the computed
|
||||
* default on both the tile's range and the chart's bands, so a shaded zone
|
||||
* never ignores an answer given on `/targets`. Each override goes through
|
||||
* `getEffectiveRange`, so its orange wings match the `/targets` page and the
|
||||
* doctor report rather than a second convention. A metric without an override
|
||||
* keeps exactly the band it drew before. Required rather than optional so
|
||||
* every call site is named by the compiler.
|
||||
*
|
||||
* v1.34 introduced this for weight alone; the other three kept the age / sex
|
||||
* default regardless of the person's own target until v1.44.1.
|
||||
*/
|
||||
export function buildDashboardBands(profile: {
|
||||
dateOfBirth: Date | null;
|
||||
gender: ProfileSex;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}): DashboardTargetBands {
|
||||
const bpTargets = profile.dateOfBirth
|
||||
? getBpTargets(profile.dateOfBirth)
|
||||
: null;
|
||||
const rangeProfile = {
|
||||
heightCm: profile.heightCm,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.gender,
|
||||
};
|
||||
const ownTarget = (metric: ThresholdMetric) =>
|
||||
resolveThresholdOverride(profile.thresholdsJson, metric);
|
||||
const ownRange = (metric: ThresholdMetric): TrafficRange | null => {
|
||||
const override = ownTarget(metric);
|
||||
return override
|
||||
? getEffectiveRange(metric, rangeProfile, { [metric]: override }).range
|
||||
: null;
|
||||
};
|
||||
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
rangeProfile,
|
||||
profile.thresholdsJson,
|
||||
);
|
||||
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
|
||||
// The percentile and body-fat tables carry two sexes and no third row;
|
||||
// both helpers answer a null with a neutral midpoint rather than a side.
|
||||
const referenceSex = binaryReferenceSex(profile.gender);
|
||||
const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex);
|
||||
const bodyFatRange = getBodyFatTargetRange(referenceSex);
|
||||
// v1.34 — an explicit target outranks the height-derived WHO band. Without
|
||||
// this the dashboard kept shading a band the user had already answered, and
|
||||
// the answer lived on `/targets` where nothing else could see it. The
|
||||
// override is routed through `getEffectiveRange` rather than widened here,
|
||||
// so the orange wings match the `/targets` page and the doctor report
|
||||
// exactly instead of inventing a second convention. No height and no target
|
||||
// → no band at all, unchanged.
|
||||
const weightRange = profile.weightTargetOverride
|
||||
? getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{
|
||||
heightCm: profile.heightCm,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.gender,
|
||||
},
|
||||
{ WEIGHT: profile.weightTargetOverride },
|
||||
).range
|
||||
: profile.heightCm
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
const defaultPulse = getPersonalizedPulseTarget(pulseAge, referenceSex);
|
||||
const pulseRange: TrafficRange = ownRange("PULSE") ?? {
|
||||
greenMin: defaultPulse.greenMin,
|
||||
greenMax: defaultPulse.greenMax,
|
||||
orangeMin: defaultPulse.orangeMin,
|
||||
orangeMax: defaultPulse.orangeMax,
|
||||
};
|
||||
const ownBodyFat = ownRange("BODY_FAT");
|
||||
const bodyFatRange = ownBodyFat
|
||||
? { min: ownBodyFat.greenMin, max: ownBodyFat.greenMax }
|
||||
: getBodyFatTargetRange(referenceSex);
|
||||
// No height and no target → no weight band at all, unchanged.
|
||||
const weightRange =
|
||||
ownRange("WEIGHT") ??
|
||||
(profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null);
|
||||
const weightBands = weightRange
|
||||
? buildBandsFromTrafficRange(weightRange, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
const bpSysRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseBands = [
|
||||
{
|
||||
min: 30,
|
||||
max: pulseTarget.orangeMin,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.16,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "var(--success)",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.orangeMax,
|
||||
max: 220,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.16,
|
||||
},
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands = buildTrafficLightBands(
|
||||
bodyFatRange.min,
|
||||
bodyFatRange.max,
|
||||
{
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
},
|
||||
);
|
||||
// The tile colours a reading against the same band the chart shades: the
|
||||
// person's own range with its effective wings, else the age default's.
|
||||
const bpSysRange =
|
||||
ownRange("BLOOD_PRESSURE_SYS") ??
|
||||
(bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null);
|
||||
const bpDiaRange =
|
||||
ownRange("BLOOD_PRESSURE_DIA") ??
|
||||
(bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null);
|
||||
const pulseBands = buildBandsFromTrafficRange(pulseRange, {
|
||||
lowerBound: 30,
|
||||
upperBound: 220,
|
||||
});
|
||||
const bodyFatBands = ownBodyFat
|
||||
? buildBandsFromTrafficRange(ownBodyFat, { lowerBound: 2, upperBound: 55 })
|
||||
: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
});
|
||||
return {
|
||||
bpTargets,
|
||||
bpSysRange,
|
||||
bpDiaRange,
|
||||
pulseDisplayRange: {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
},
|
||||
pulseDisplayRange: pulseRange,
|
||||
pulseBands,
|
||||
bodyFatRange,
|
||||
bodyFatBands,
|
||||
@@ -187,7 +180,7 @@ export interface BandProfile {
|
||||
dateOfBirth: Date | null;
|
||||
gender: ProfileSex;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +213,6 @@ export function viewerBandProfile(
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
};
|
||||
}
|
||||
@@ -1489,10 +1489,11 @@ export async function buildDashboardSnapshot(
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
// v1.34 — the user's own weight target, when they set one, replaces the
|
||||
// height-derived band on the tile + the chart. Resolved off the same
|
||||
// `thresholdsJson` blob the health score reads.
|
||||
weightTargetOverride,
|
||||
// The user's own targets, where set, replace the computed default on
|
||||
// every tile and chart band (weight, blood pressure, resting pulse,
|
||||
// body fat). Resolved off the same `thresholdsJson` blob the health
|
||||
// score and the tile directions read.
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
}),
|
||||
tiles: {
|
||||
summaries: slim.summaries,
|
||||
|
||||
@@ -148,6 +148,44 @@ describe("classifyBriefingFailure", () => {
|
||||
).toBe("provider");
|
||||
});
|
||||
|
||||
it("claims a timeout only for a deadline once the failure kind is known (#1267)", () => {
|
||||
const at = (failureKind: string) =>
|
||||
classifyBriefingFailure({
|
||||
reason: "all-providers-failed",
|
||||
httpStatus: 0,
|
||||
failureKind,
|
||||
});
|
||||
expect(at("timeout")).toBe("timeout");
|
||||
expect(at("headers_timeout")).toBe("timeout");
|
||||
// A 200 that reasoned and never answered is the model's output.
|
||||
expect(at("reasoning_only")).toBe("format");
|
||||
expect(at("empty_response")).toBe("format");
|
||||
// A refused or dropped connection is the provider.
|
||||
expect(at("refused")).toBe("provider");
|
||||
expect(at("socket")).toBe("provider");
|
||||
});
|
||||
|
||||
it("reads the failure kind back from the marker (#1267)", async () => {
|
||||
create.mockResolvedValue({});
|
||||
await recordBriefingFailure({
|
||||
userId: "u1",
|
||||
reason: "all-providers-failed",
|
||||
httpStatus: 0,
|
||||
failureKind: "reasoning_only",
|
||||
});
|
||||
const details = create.mock.calls[0][0].data.details as string;
|
||||
expect(JSON.parse(details).failureKind).toBe("reasoning_only");
|
||||
findFirst.mockResolvedValue({
|
||||
createdAt: new Date("2026-06-28T10:00:00Z"),
|
||||
details,
|
||||
});
|
||||
const result = await readBriefingFailure({
|
||||
userId: "u1",
|
||||
since: new Date("2026-06-28T09:00:00Z"),
|
||||
});
|
||||
expect(result?.failureClass).toBe("format");
|
||||
});
|
||||
|
||||
it("maps a missing / sentinel-zero status to timeout", () => {
|
||||
expect(classifyBriefingFailure({ reason: "provider-error" })).toBe(
|
||||
"timeout",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isTimeoutFailure,
|
||||
type ProviderFailureKind,
|
||||
} from "@/lib/ai/failure-kind";
|
||||
|
||||
/**
|
||||
* Dated failure marker for the daily-briefing generation.
|
||||
@@ -22,9 +26,9 @@ export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
|
||||
/**
|
||||
* Coarse class of a briefing-generation failure, used by the read path to
|
||||
* point the empty-state hint at the right lever:
|
||||
* - `timeout` — the generation ran out of time (no upstream HTTP status,
|
||||
* or a transport/abort error). Lever: raise the AI response
|
||||
* timeout for a slow local model.
|
||||
* - `timeout` — the generation ran out of time (a deadline, or a marker
|
||||
* from before the failure kind was recorded). Lever: raise
|
||||
* the AI response timeout for a slow local model.
|
||||
* - `auth` — the provider rejected the request (4xx that is not 429).
|
||||
* Lever: re-check the provider / API key in Settings.
|
||||
* - `rate-limit` — the provider returned 429.
|
||||
@@ -39,14 +43,19 @@ export type BriefingFailureClass =
|
||||
* Classify a failure from its recorded reason and (optional) upstream HTTP
|
||||
* status. A reason of `invalid-json` is always a format miss; a 401/403 (or any
|
||||
* other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a
|
||||
* 5xx is a generic provider failure. With no status at all — the timeout /
|
||||
* transport / abort path, which is the dominant briefing failure on a slow
|
||||
* self-hosted backend — the most useful lever is the response-timeout, so it
|
||||
* classifies as `timeout`.
|
||||
* 5xx is a generic provider failure. With no status at all, the recorded
|
||||
* failure kind decides (#1267): a deadline is `timeout`, a reply without an
|
||||
* answer is `format`, any other transport failure is `provider`. A marker
|
||||
* without a kind keeps the old `timeout` reading.
|
||||
*/
|
||||
export function classifyBriefingFailure(args: {
|
||||
reason: string;
|
||||
httpStatus?: number | null;
|
||||
/**
|
||||
* #1267 — what the first provider call ran into. A marker written before
|
||||
* the field existed has none and keeps the old reading.
|
||||
*/
|
||||
failureKind?: string | null;
|
||||
}): BriefingFailureClass {
|
||||
if (args.reason === "invalid-json") return "format";
|
||||
const status = args.httpStatus;
|
||||
@@ -55,8 +64,22 @@ export function classifyBriefingFailure(args: {
|
||||
if (status >= 400 && status < 500) return "auth";
|
||||
if (status >= 500) return "provider";
|
||||
}
|
||||
// No / sentinel-zero upstream status: a transport-level timeout or abort.
|
||||
return "timeout";
|
||||
// No / sentinel-zero upstream status. Only a deadline is a timeout: a reply
|
||||
// that came back without an answer is the model's output, and a refused or
|
||||
// dropped connection is the provider, and neither is helped by a longer
|
||||
// response timeout.
|
||||
const kind = args.failureKind ?? null;
|
||||
if (kind === null || isTimeoutFailure(kind as ProviderFailureKind)) {
|
||||
return "timeout";
|
||||
}
|
||||
if (
|
||||
kind === "empty_response" ||
|
||||
kind === "reasoning_only" ||
|
||||
kind === "parse"
|
||||
) {
|
||||
return "format";
|
||||
}
|
||||
return "provider";
|
||||
}
|
||||
|
||||
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
|
||||
@@ -79,6 +102,8 @@ export async function recordBriefingFailure(args: {
|
||||
* timeout and point the hint at the right lever.
|
||||
*/
|
||||
httpStatus?: number | null;
|
||||
/** #1267 — the first hop's `FallbackHop.failureKind`, when known. */
|
||||
failureKind?: ProviderFailureKind | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
@@ -89,6 +114,7 @@ export async function recordBriefingFailure(args: {
|
||||
dateKey: dateKey(),
|
||||
reason: args.reason,
|
||||
httpStatus: args.httpStatus ?? null,
|
||||
failureKind: args.failureKind ?? null,
|
||||
locale: args.locale ?? null,
|
||||
triedAt: new Date().toISOString(),
|
||||
}),
|
||||
@@ -128,14 +154,19 @@ export async function readBriefingFailure(args: {
|
||||
|
||||
let reason = "unknown";
|
||||
let httpStatus: number | null = null;
|
||||
let failureKind: string | null = null;
|
||||
if (latest.details) {
|
||||
try {
|
||||
const parsed = JSON.parse(latest.details) as {
|
||||
reason?: unknown;
|
||||
httpStatus?: unknown;
|
||||
failureKind?: unknown;
|
||||
};
|
||||
if (typeof parsed.reason === "string") reason = parsed.reason;
|
||||
if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus;
|
||||
if (typeof parsed.failureKind === "string") {
|
||||
failureKind = parsed.failureKind;
|
||||
}
|
||||
} catch {
|
||||
// Malformed marker payload — still report the failure, generic reason.
|
||||
}
|
||||
@@ -143,6 +174,6 @@ export async function readBriefingFailure(args: {
|
||||
return {
|
||||
triedAt: latest.createdAt.toISOString(),
|
||||
reason,
|
||||
failureClass: classifyBriefingFailure({ reason, httpStatus }),
|
||||
failureClass: classifyBriefingFailure({ reason, httpStatus, failureKind }),
|
||||
};
|
||||
}
|
||||
@@ -113,6 +113,7 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import { providerFailureKind } from "@/lib/ai/failure-kind";
|
||||
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
|
||||
import {
|
||||
@@ -1286,6 +1287,10 @@ export async function generateComprehensiveInsight(
|
||||
reason,
|
||||
locale,
|
||||
httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null,
|
||||
failureKind:
|
||||
e instanceof AllProvidersFailedError
|
||||
? (e.attempts?.[0]?.failureKind ?? null)
|
||||
: providerFailureKind(e).failure,
|
||||
});
|
||||
const credentialExpired =
|
||||
e instanceof AllProvidersFailedError && e.primaryCredentialExpired;
|
||||
|
||||
@@ -1186,8 +1186,8 @@ export const dashboardSnapshotResponse = z
|
||||
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
|
||||
),
|
||||
// v1.18.6 — server-computed band / target math (audit finding #3),
|
||||
// resolved from the user's own profile facts (DOB / gender / height /
|
||||
// weight-target override). The dashboard reads these instead of
|
||||
// resolved from the user's own profile facts (DOB / gender / height) and
|
||||
// their own targets. The dashboard reads these instead of
|
||||
// recomputing client-side; always present (every sub-field falls back
|
||||
// to a neutral default rather than being omitted).
|
||||
targetBands: z
|
||||
@@ -1201,7 +1201,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
|
||||
"Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
bpSysRange: z
|
||||
.object({
|
||||
@@ -1212,7 +1212,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Systolic traffic-light range. Null when the profile has no date of birth.",
|
||||
"Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
bpDiaRange: z
|
||||
.object({
|
||||
@@ -1223,7 +1223,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Diastolic traffic-light range. Null when the profile has no date of birth.",
|
||||
"Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
pulseDisplayRange: z
|
||||
.object({
|
||||
@@ -1233,7 +1233,7 @@ export const dashboardSnapshotResponse = z
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.describe(
|
||||
"Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
|
||||
"Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present.",
|
||||
),
|
||||
pulseBands: z
|
||||
.array(
|
||||
@@ -1246,11 +1246,13 @@ export const dashboardSnapshotResponse = z
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
|
||||
"Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.",
|
||||
),
|
||||
bodyFatRange: z
|
||||
.object({ min: z.number(), max: z.number() })
|
||||
.describe("Gender-aware body-fat target range. Always present."),
|
||||
.describe(
|
||||
"Body-fat target range: the user's own target where set, else the gender-aware default. Always present.",
|
||||
),
|
||||
bodyFatBands: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -1261,7 +1263,9 @@ export const dashboardSnapshotResponse = z
|
||||
strokeOpacity: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.describe("Body-fat chart bands. Always present."),
|
||||
.describe(
|
||||
"Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.",
|
||||
),
|
||||
weightRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectOwnTargetRange } from "../use-own-target-range";
|
||||
|
||||
const range = { greenMin: 88, greenMax: 92, orangeMin: 87.4, orangeMax: 92.6 };
|
||||
|
||||
describe("selectOwnTargetRange", () => {
|
||||
it("returns the resolved range when the person set the target", () => {
|
||||
expect(
|
||||
selectOwnTargetRange(
|
||||
{ effective: { OXYGEN_SATURATION: { range, isOverride: true } } },
|
||||
"OXYGEN_SATURATION",
|
||||
),
|
||||
).toEqual(range);
|
||||
});
|
||||
|
||||
it("returns null for a default range, so the chart keeps its own band", () => {
|
||||
expect(
|
||||
selectOwnTargetRange(
|
||||
{ effective: { OXYGEN_SATURATION: { range, isOverride: false } } },
|
||||
"OXYGEN_SATURATION",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null before the read lands or for an absent metric", () => {
|
||||
expect(selectOwnTargetRange(null, "OXYGEN_SATURATION")).toBeNull();
|
||||
expect(selectOwnTargetRange({ effective: {} }, "PULSE")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { ThresholdMetric } from "@/lib/analytics/effective-range";
|
||||
import type { TrafficRange } from "@/lib/analytics/value-bands";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The slice of `GET /api/user/thresholds` a chart band needs. */
|
||||
interface ThresholdsSlice {
|
||||
effective: Partial<
|
||||
Record<string, { range: TrafficRange | null; isOverride: boolean }>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own target band for one metric, as the server resolved it, or
|
||||
* `null` when they have not set one (the chart then keeps its default band).
|
||||
*
|
||||
* `/api/user/thresholds` resolves every override through `getEffectiveRange`,
|
||||
* orange wings included, so the page draws the resolved range rather than
|
||||
* re-deriving one. Shares the `userThresholds()` cache entry with the target
|
||||
* editor, which invalidates it on save, so editing a target repaints the
|
||||
* chart without a reload.
|
||||
*/
|
||||
export function useOwnTargetRange(
|
||||
metric: ThresholdMetric,
|
||||
): TrafficRange | null {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: () => apiGet<ThresholdsSlice>("/api/user/thresholds"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
return selectOwnTargetRange(data ?? null, metric);
|
||||
}
|
||||
|
||||
/** Pure projection, exported for the unit test. */
|
||||
export function selectOwnTargetRange(
|
||||
data: ThresholdsSlice | null,
|
||||
metric: ThresholdMetric,
|
||||
): TrafficRange | null {
|
||||
const entry = data?.effective?.[metric];
|
||||
if (!entry?.isOverride) return null;
|
||||
return entry.range ?? null;
|
||||
}
|
||||
@@ -147,6 +147,14 @@ let cachedOperatorApproved: Agent | null = null;
|
||||
|
||||
function createPinnedDispatcher(policy: AddressPolicy): Agent {
|
||||
return new Agent({
|
||||
// #1267 — undici's own 300 s header and body timers would cut a call the
|
||||
// caller allowed longer: a local model with a 600 s response timeout
|
||||
// answers a non-streamed completion only when it is done, so its headers
|
||||
// can take longer than 300 s, and the failure surfaced as a bare
|
||||
// "network" error. Every `safeFetch` call carries its own
|
||||
// `AbortSignal.timeout`, so that signal is the one deadline.
|
||||
headersTimeout: 0,
|
||||
bodyTimeout: 0,
|
||||
connect: {
|
||||
lookup:
|
||||
policy === "public" ? pinnedPublicLookup : pinnedOperatorApprovedLookup,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The bottom sheet's gesture arithmetic, free of the DOM so every rule can be
|
||||
* tested on numbers: when a drag counts, how far the sheet follows, how fast
|
||||
* the finger left, and where the sheet comes to rest.
|
||||
*
|
||||
* Offsets are the sheet's `translateY` in px: 0 is fully raised, positive is
|
||||
* further down. A sheet with a preview step rests at `previewOffset` (its
|
||||
* full height less the preview height) until it is pulled up to 0.
|
||||
*/
|
||||
|
||||
/** A release this far below the lowest resting place closes the sheet, as a share of the height still visible there. */
|
||||
export const SHEET_DISMISS_RATIO = 0.25;
|
||||
/** A downward release faster than this (px/ms) closes the sheet. */
|
||||
export const SHEET_FLICK_VELOCITY = 0.5;
|
||||
/** Travel (px) before a touch is read as vertical or sideways. */
|
||||
export const SHEET_AXIS_SLOP = 8;
|
||||
/** The most the sheet rises above its top resting place, however far the finger goes. */
|
||||
export const SHEET_OVERDRAG = 24;
|
||||
/** Only the movement in this window (ms) before release counts towards the velocity. */
|
||||
export const SHEET_VELOCITY_WINDOW = 100;
|
||||
|
||||
export type SheetAxis = "x" | "y";
|
||||
export type SheetSnap = "full" | "preview";
|
||||
export type SheetSettle = { kind: "close" } | { kind: "snap"; snap: SheetSnap };
|
||||
|
||||
/**
|
||||
* Which way a touch is going, once it has gone far enough to tell. Null while
|
||||
* it is still inside the slop. A tie goes sideways, so a diagonal start never
|
||||
* takes a slider or a wide table away from its own gesture.
|
||||
*/
|
||||
export function lockAxis(
|
||||
dx: number,
|
||||
dy: number,
|
||||
slop: number = SHEET_AXIS_SLOP,
|
||||
): SheetAxis | null {
|
||||
if (Math.abs(dx) < slop && Math.abs(dy) < slop) return null;
|
||||
return Math.abs(dy) > Math.abs(dx) ? "y" : "x";
|
||||
}
|
||||
|
||||
/**
|
||||
* Travel past the top: 1:1 at first, then ever stiffer, never more than
|
||||
* `limit`.
|
||||
*/
|
||||
export function rubberBand(
|
||||
distance: number,
|
||||
limit: number = SHEET_OVERDRAG,
|
||||
): number {
|
||||
if (distance <= 0) return 0;
|
||||
return (distance * limit) / (distance + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sheet sits for a finger `delta` px from where the drag started,
|
||||
* the sheet then resting at `base`. Down and back up to the top it follows
|
||||
* the finger exactly; above the top it resists.
|
||||
*/
|
||||
export function dragOffset(base: number, delta: number): number {
|
||||
const raw = base + delta;
|
||||
return raw >= 0 ? raw : -rubberBand(-raw);
|
||||
}
|
||||
|
||||
export interface SheetSample {
|
||||
/** ms */
|
||||
t: number;
|
||||
/** offset, px */
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's speed at release (px/ms, positive downward), from the samples
|
||||
* inside the velocity window. A finger that came to rest before letting go
|
||||
* leaves fewer than two samples there and so no speed.
|
||||
*/
|
||||
export function releaseVelocity(
|
||||
samples: readonly SheetSample[],
|
||||
now: number,
|
||||
window: number = SHEET_VELOCITY_WINDOW,
|
||||
): number {
|
||||
let first: SheetSample | null = null;
|
||||
let last: SheetSample | null = null;
|
||||
for (const sample of samples) {
|
||||
if (now - sample.t > window) continue;
|
||||
if (first === null) first = sample;
|
||||
last = sample;
|
||||
}
|
||||
if (first === null || last === null || last.t <= first.t) return 0;
|
||||
return (last.y - first.y) / (last.t - first.t);
|
||||
}
|
||||
|
||||
export interface SheetSettleInput {
|
||||
offset: number;
|
||||
velocity: number;
|
||||
/** The sheet's own height, px. */
|
||||
height: number;
|
||||
/** The preview step's resting offset; null for a sheet without one. */
|
||||
previewOffset: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a released sheet goes.
|
||||
*
|
||||
* - Further than a quarter of its visible height below its lowest resting
|
||||
* place: it closes.
|
||||
* - Flicked down: it closes once it is below that resting place, otherwise
|
||||
* it drops to the preview step (or back to the top without one).
|
||||
* - Flicked up: it rises to the top.
|
||||
* - Otherwise it goes to the nearer resting place.
|
||||
*/
|
||||
export function settleSheet({
|
||||
offset,
|
||||
velocity,
|
||||
height,
|
||||
previewOffset,
|
||||
}: SheetSettleInput): SheetSettle {
|
||||
const lowest = previewOffset ?? 0;
|
||||
const visible = Math.max(height - lowest, 1);
|
||||
if (offset - lowest > SHEET_DISMISS_RATIO * visible) return { kind: "close" };
|
||||
if (velocity > SHEET_FLICK_VELOCITY) {
|
||||
if (offset > lowest) return { kind: "close" };
|
||||
return { kind: "snap", snap: previewOffset === null ? "full" : "preview" };
|
||||
}
|
||||
if (velocity < -SHEET_FLICK_VELOCITY || previewOffset === null) {
|
||||
return { kind: "snap", snap: "full" };
|
||||
}
|
||||
return {
|
||||
kind: "snap",
|
||||
snap: offset < previewOffset / 2 ? "full" : "preview",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop's opacity: whole while the sheet is at or above its lowest
|
||||
* resting place, fading in step with the sheet on its way out.
|
||||
*/
|
||||
export function backdropOpacity(
|
||||
offset: number,
|
||||
lowest: number,
|
||||
height: number,
|
||||
): number {
|
||||
const travel = Math.max(height - lowest, 1);
|
||||
const out = (offset - lowest) / travel;
|
||||
return Math.min(1, Math.max(0, 1 - out));
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a preview sheet's footer is lifted against the sheet's own
|
||||
* offset, so it stays on the bottom edge of the window between the preview
|
||||
* step and the top, and leaves with the sheet below the preview step.
|
||||
*/
|
||||
export function footerLift(offset: number, previewOffset: number): number {
|
||||
return Math.min(Math.max(offset, 0), previewOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a vertical touch that began inside scrolling content moves the
|
||||
* sheet. Content that is scrolled at all scrolls first; at the very top a
|
||||
* downward pull moves the sheet, and an upward one only raises a sheet that
|
||||
* is still on its preview step (otherwise it scrolls the content).
|
||||
*/
|
||||
export function contentDrags({
|
||||
scrollTop,
|
||||
dy,
|
||||
snap,
|
||||
hasPreview,
|
||||
}: {
|
||||
scrollTop: number;
|
||||
dy: number;
|
||||
snap: SheetSnap;
|
||||
hasPreview: boolean;
|
||||
}): boolean {
|
||||
if (scrollTop > 0) return false;
|
||||
if (dy > 0) return true;
|
||||
return hasPreview && snap === "preview";
|
||||
}
|
||||
|
||||
/** The `translateY` of a computed `transform` value (`none`, `matrix()` or `matrix3d()`). */
|
||||
export function translateYOf(transform: string): number {
|
||||
if (!transform || transform === "none") return 0;
|
||||
const values = transform
|
||||
.slice(transform.indexOf("(") + 1, transform.lastIndexOf(")"))
|
||||
.split(",")
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (transform.startsWith("matrix3d(")) return values[13] ?? 0;
|
||||
if (transform.startsWith("matrix(")) return values[5] ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A target the person sets on `/targets` reaches the dashboard's chart bands.
|
||||
*
|
||||
* The snapshot resolved the blood-pressure, resting-pulse and body-fat bands
|
||||
* from the profile alone (age, sex) and took only the weight target into
|
||||
* account, so the zones under the blood-pressure chart stayed on the age
|
||||
* default after the person had entered their own band. The resolved bands
|
||||
* are what the web dashboard draws and what a native client reads, so the
|
||||
* proof runs through the real routes: write the target through
|
||||
* `PUT /api/user/thresholds`, read `GET /api/dashboard/snapshot` back from
|
||||
* the same Postgres, with the snapshot's cache cell in between.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedUser(): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "own-target-bands",
|
||||
email: "own-target-bands@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
dateOfBirth: new Date("1960-03-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 178,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60 * 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
interface Bands {
|
||||
bpTargets: {
|
||||
sysLow: number;
|
||||
sysHigh: number;
|
||||
diaLow: number;
|
||||
diaHigh: number;
|
||||
} | null;
|
||||
pulseDisplayRange: { greenMin: number; greenMax: number };
|
||||
bodyFatRange: { min: number; max: number };
|
||||
}
|
||||
|
||||
async function readBands(): Promise<Bands> {
|
||||
const { GET } = await import("@/app/api/dashboard/snapshot/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { targetBands: Bands } };
|
||||
return body.data.targetBands;
|
||||
}
|
||||
|
||||
async function putTargets(payload: unknown): Promise<void> {
|
||||
const { PUT } = await import("@/app/api/user/thresholds/route");
|
||||
const res = await PUT(
|
||||
new NextRequest("http://localhost/api/user/thresholds", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
describe("dashboard bands follow the person's own targets (real Postgres)", () => {
|
||||
it("replaces the age default once a target is saved", async () => {
|
||||
await seedUser();
|
||||
|
||||
const before = await readBands();
|
||||
// The premise: an age default exists and differs from the target below.
|
||||
expect(before.bpTargets).not.toBeNull();
|
||||
expect(before.bpTargets?.sysHigh).not.toBe(150);
|
||||
|
||||
await putTargets({
|
||||
BLOOD_PRESSURE_SYS: { min: 120, max: 150 },
|
||||
BLOOD_PRESSURE_DIA: { min: 75, max: 95 },
|
||||
PULSE: { min: 48, max: 58 },
|
||||
BODY_FAT: { min: 12, max: 16 },
|
||||
});
|
||||
|
||||
// Read again: the save evicts the cached snapshot, so this is a rebuild
|
||||
// against the stored blob, not the cell from the first read.
|
||||
const after = await readBands();
|
||||
expect(after.bpTargets).toEqual({
|
||||
sysLow: 120,
|
||||
sysHigh: 150,
|
||||
diaLow: 75,
|
||||
diaHigh: 95,
|
||||
});
|
||||
expect(after.pulseDisplayRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 48, greenMax: 58 }),
|
||||
);
|
||||
expect(after.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user