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:
Marc-André Bombeck committed 2026-10-22 16:43:28 +02:00
commit 9c8972bc96
86 files changed
+5051 -487

No files matched your search

+16 -16
View File
@@ -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
View File
@@ -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
+76
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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) |
+536
View File
@@ -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);
});
});
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -6558,7 +6558,8 @@
"medium": "보통",
"high": "높음"
},
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다.",
"backgroundNote": "일일 브리핑 같은 백그라운드 작업은 이 설정을 쓰지 않습니다. 최대 보통 수준으로 생각하며, 운영자가 더 낮은 한도를 정하면 그보다 낮게, 절약 단계에서는 전혀 생각하지 않습니다."
},
"providerCardDescription": "제공자마다 키와 모델을 설정하고, 아래 순서로 HealthLog가 먼저 쓸 제공자를 정해요.",
"runtimeTitle": "확인 및 새로고침",
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+10
View File
@@ -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
View File
@@ -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",
+22
View File
@@ -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
+61 -93
View File
@@ -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
View File
@@ -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);
});
});
+8 -5
View File
@@ -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"/,
);
});
});
+5
View File
@@ -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({
+31
View File
@@ -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("&quot;", '"');
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);
});
});
+9 -8
View File
@@ -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
View File
@@ -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("/");
+53 -4
View File
@@ -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;
}
+17 -67
View File
@@ -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>
);
+13 -6
View File
@@ -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 ? (
+38 -6
View File
@@ -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
+37 -15
View File
@@ -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.
//
+4 -2
View File
@@ -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 (
+5 -5
View File
@@ -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":
+5 -1
View File
@@ -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: () => {
+10 -4
View File
@@ -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");
});
});
+292
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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"
+373
View File
@@ -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);
});
});
});
+16 -1
View File
@@ -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
+8 -2
View File
@@ -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;
+506
View File
@@ -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);
});
});
+239
View File
@@ -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);
});
});
+31 -1
View File
@@ -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,
);
});
});
+189
View File
@@ -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"]);
});
});
+9 -1
View File
@@ -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;
}
+166
View File
@@ -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
View File
@@ -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,
+56 -5
View File
@@ -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;
+35 -3
View File
@@ -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,
},
});
}
+9
View File
@@ -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;
+58
View File
@@ -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 })),
+131 -13
View File
@@ -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
View File
@@ -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,
};
}
+5 -4
View File
@@ -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",
+41 -10
View File
@@ -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;
+13 -9
View File
@@ -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();
});
});
+48
View File
@@ -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;
}
+8
View File
@@ -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,
+186
View File
@@ -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 });
});
});