From b1fe1f0597464d88d67c33897b4221d2287be2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 19:39:38 +0100 Subject: [PATCH 01/25] fix(ai): hold a refused reasoning field for a day A gateway that refuses the reasoning field does so by its configuration, so the lesson now holds for 24 hours instead of one. At the hour every expiry cost one refused request, a few a day on a gateway that runs the briefings unattended. Lessons read from a single reply keep the hour. Refs #1279 --- src/lib/ai/__tests__/local-reasoning.test.ts | 8 +++- src/lib/ai/__tests__/openai-reasoning.test.ts | 8 +++- .../reasoning/__tests__/dialect-cache.test.ts | 47 +++++++++++++++++-- src/lib/ai/reasoning/dialect-cache.ts | 30 ++++++++++-- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/lib/ai/__tests__/local-reasoning.test.ts b/src/lib/ai/__tests__/local-reasoning.test.ts index 30e5f091f..c492e4f29 100644 --- a/src/lib/ai/__tests__/local-reasoning.test.ts +++ b/src/lib/ai/__tests__/local-reasoning.test.ts @@ -533,7 +533,7 @@ describe("local reasoning refusals the client must not learn from", () => { expect(body(mock, 1).reasoning_effort).toBe("low"); }); - it("sends the field again an hour after a refusal", async () => { + it("holds a refusal for a day, then sends the field again", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const refusal = readFileSync( @@ -550,9 +550,13 @@ describe("local reasoning refusals the client must not learn from", () => { const wanted = params({ reasoning: { effort: "low", summaries: true } }); await client().generateCompletion(wanted); expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + // #1279: the hour cost one refused request per expiry. vi.advanceTimersByTime(60 * 60 * 1000); await client().generateCompletion(wanted); - expect(body(mock, 2).reasoning_effort).toBe("low"); + expect(body(mock, 2)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + await client().generateCompletion(wanted); + expect(body(mock, 3).reasoning_effort).toBe("low"); } finally { vi.useRealTimers(); } diff --git a/src/lib/ai/__tests__/openai-reasoning.test.ts b/src/lib/ai/__tests__/openai-reasoning.test.ts index 176ce2763..fd38826e4 100644 --- a/src/lib/ai/__tests__/openai-reasoning.test.ts +++ b/src/lib/ai/__tests__/openai-reasoning.test.ts @@ -348,7 +348,7 @@ describe("OpenAI reasoning refusals the client must not learn from", () => { expect(body(mock, 1).reasoning_effort).toBe("low"); }); - it("sends reasoning again an hour after a refusal", async () => { + it("holds a refusal for a day, then sends reasoning again", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const mock = vi @@ -365,9 +365,13 @@ describe("OpenAI reasoning refusals the client must not learn from", () => { const wanted = params({ reasoning: { effort: "low", summaries: false } }); await c.generateCompletion(wanted); expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + // #1279: the hour cost one refused request per expiry. vi.advanceTimersByTime(60 * 60 * 1000); await c.generateCompletion(wanted); - expect(body(mock, 2).reasoning_effort).toBe("low"); + expect(body(mock, 2)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + await c.generateCompletion(wanted); + expect(body(mock, 3).reasoning_effort).toBe("low"); } finally { vi.useRealTimers(); } diff --git a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts index 37014bf91..94450e9a6 100644 --- a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts +++ b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts @@ -9,8 +9,12 @@ vi.mock("@/lib/logging/context", () => ({ })); import { + ANSWERS_ONLY_UNREASONED, annotateReasoningDowngrade, DIALECT_TTL_MS, + FIELD_REFUSED_REASONS_REGARDLESS, + FIELD_REFUSED_TTL_MS, + REASONS_REGARDLESS, isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, @@ -56,19 +60,54 @@ describe("reasoning dialect cache", () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const key = { provider: "openai", endpoint: "http://g/v1", model: "m" }; - rememberReasoningDialect(key, "none"); + rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED); vi.advanceTimersByTime(DIALECT_TTL_MS - 1); - expect(learnedReasoningDialect(key)).toBe("none"); + expect(learnedReasoningDialect(key)).toBe(ANSWERS_ONLY_UNREASONED); vi.advanceTimersByTime(1); expect(learnedReasoningDialect(key)).toBeUndefined(); - rememberReasoningDialect(key, "none"); + rememberReasoningDialect(key, REASONS_REGARDLESS); vi.advanceTimersByTime(DIALECT_TTL_MS / 2); - expect(learnedReasoningDialect(key)).toBe("none"); + expect(learnedReasoningDialect(key)).toBe(REASONS_REGARDLESS); expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000); } finally { vi.useRealTimers(); } }); + + // #1279: at the hour, a gateway that refuses the field paid one refused + // request per expiry, a few a day. + it.each(["none", FIELD_REFUSED_REASONS_REGARDLESS])( + "holds a refused field (%s) for a day, not an hour", + (value) => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const key = { provider: "openai", endpoint: "http://g/v1", model: "m" }; + rememberReasoningDialect(key, value); + vi.advanceTimersByTime(DIALECT_TTL_MS * 2); + expect(learnedReasoningDialect(key)).toBe(value); + vi.advanceTimersByTime(FIELD_REFUSED_TTL_MS - DIALECT_TTL_MS * 2 - 1); + expect(learnedReasoningDialect(key)).toBe(value); + vi.advanceTimersByTime(1); + expect(learnedReasoningDialect(key)).toBeUndefined(); + expect(FIELD_REFUSED_TTL_MS).toBe(24 * 60 * 60 * 1000); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("gives a behavioural lesson that replaces a refused field the hour", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const key = { provider: "local", endpoint: "http://l/v1", model: "m" }; + rememberReasoningDialect(key, "none"); + rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED); + vi.advanceTimersByTime(DIALECT_TTL_MS); + expect(learnedReasoningDialect(key)).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); }); describe("isReasoningParameterRejection", () => { diff --git a/src/lib/ai/reasoning/dialect-cache.ts b/src/lib/ai/reasoning/dialect-cache.ts index b1d7f841c..2258dd743 100644 --- a/src/lib/ai/reasoning/dialect-cache.ts +++ b/src/lib/ai/reasoning/dialect-cache.ts @@ -1,8 +1,9 @@ /** * v1.41 — what an endpoint answered when it was asked to reason, learned at - * runtime and kept for an hour (`DIALECT_TTL_MS`), so an endpoint that - * gains the parameter, or a downgrade learned from a misread error, heals - * without a restart. + * runtime and kept for an hour (`DIALECT_TTL_MS`), a refused reasoning field + * for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the + * parameter, or a downgrade learned from a misread error, heals without a + * restart. * * Every client that sends reasoning parameters to a wire it does not control * (a gateway, a local server, a model name the client has never seen, a Codex @@ -76,6 +77,18 @@ export function refusesReasoningField(learned: string | undefined): boolean { /** How long a learned downgrade holds before the full request is tried again. */ export const DIALECT_TTL_MS = 60 * 60 * 1000; +/** + * #1279 — how long a refused reasoning field holds (`refusesReasoningField`). + * A gateway that refuses the field does so by its configuration (LiteLLM + * without `drop_params`), which changes when its operator changes it, not + * between two calls, and only a 400 naming the field with refusal wording + * can teach it. At the hour every expiry cost one refused request, a few a + * day on a gateway that runs the briefings unattended. A day bounds that to + * one per day, and a gateway that gains the field still gets it the next day. + * The other lessons read a single reply and keep the hour. + */ +export const FIELD_REFUSED_TTL_MS = 24 * 60 * 60 * 1000; + /** * Pinned on `globalThis`: a module imported from more than one bundler layer * is instantiated once per layer (see `caches` in `server-cache.ts`), so the @@ -108,12 +121,19 @@ export function learnedReasoningDialect( return entry.value; } -/** Record what `key` accepts after it refused a richer request. */ +/** + * Record what `key` accepts after it refused a richer request. A refused + * field holds for `FIELD_REFUSED_TTL_MS`, every other lesson for + * `DIALECT_TTL_MS`. + */ export function rememberReasoningDialect( key: ReasoningDialectKey, value: string, ): void { - learned.set(cacheKey(key), { value, expiresAt: Date.now() + DIALECT_TTL_MS }); + const ttl = refusesReasoningField(value) + ? FIELD_REFUSED_TTL_MS + : DIALECT_TTL_MS; + learned.set(cacheKey(key), { value, expiresAt: Date.now() + ttl }); } /** From 1d374dd4d52c6c8a04731a3345a6b169602ed731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 19:55:20 +0100 Subject: [PATCH 02/25] refactor(ai): move the Codex reasoning wire into its own module The reasoning-field planning and the refusal learning are a self-contained part of the Codex wire. Moving them out of codex-client.ts leaves room for the per-sign-in model selection that follows without growing a file the size ratchet already holds at its limit. Refs #1287 --- .../baselines/source-file-lines.json | 4 +- src/lib/ai/codex-client.ts | 152 +----------------- src/lib/ai/codex-reasoning-wire.ts | 152 ++++++++++++++++++ 3 files changed, 161 insertions(+), 147 deletions(-) create mode 100644 src/lib/ai/codex-reasoning-wire.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 4f04a28da..7cc9a4337 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94692, + "total": 94554, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -28,7 +28,7 @@ "lib/ai/coach/snapshot.ts": 1346, "lib/ai/coach/stream-events.ts": 817, "lib/ai/coach/types.ts": 1011, - "lib/ai/codex-client.ts": 1045, + "lib/ai/codex-client.ts": 907, "lib/ai/prompts/insight-generator.ts": 1110, "lib/ai/provider.ts": 1632, "lib/analytics/score/reader.ts": 998, diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 93ad5b38a..2bcef2a08 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -11,14 +11,14 @@ import type { CompletionParams, CompletionResult, } from "./types"; -import { - annotateReasoningDowngrade, - isReasoningParameterRejection, - learnedReasoningDialect, - rememberReasoningDialect, -} from "./reasoning/dialect-cache"; -import type { ReasoningLevel } from "./reasoning/levels"; import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support"; +import { + CODEX_ENDPOINT, + asksEncryptedReasoning, + learnFromReasoningRejection, + planCodexReasoning, + type CodexReasoningWire, +} from "./codex-reasoning-wire"; import { getCachedCodexSlug, invalidateCachedCodexSlug, @@ -54,8 +54,6 @@ export { resolveCodexVisionSlug } from "./codex-slug-chain"; * longer bricks the integration. */ -const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; - /** * Triggers per spec §7b — body / status that mean "this slug is no * longer accepted, walk to the next one in the chain". @@ -207,142 +205,6 @@ function codexStateItems( ); } -// ── v1.41 reasoning wire ──────────────────────────────────────────────────── - -/** - * The Responses-API efforts in ascending order. The Codex backend names the - * values a slug accepts in its 400 body ("Supported values are: 'none', - * 'low', …"), which is how a slug's ladder is learned. - */ -const CODEX_EFFORT_ORDER = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -] as const; - -/** What one request sends: `null` is today's `reasoning: null`. */ -type CodexReasoningWire = { effort: string; summary: boolean } | null; - -/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */ -interface CodexReasoningDialect { - /** The slug refused `reasoning` altogether. */ - disabled?: true; - /** The slug refused `reasoning.summary`. */ - noSummary?: true; - /** The efforts the slug named as supported when it refused one. */ - efforts?: string[]; -} - -function dialectKey(slug: string) { - return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug }; -} - -function readCodexDialect(slug: string): CodexReasoningDialect { - const raw = learnedReasoningDialect(dialectKey(slug)); - if (!raw) return {}; - try { - return JSON.parse(raw) as CodexReasoningDialect; - } catch { - return {}; - } -} - -function wantedEffort(level: ReasoningLevel): string { - // "Off" is a real off on the current slugs: a 2026-10-06 probe answered - // `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that - // refuses it learns its lowest supported value instead. - return level === "off" ? "none" : level; -} - -/** The supported effort closest to `wanted` from below, else the lowest. */ -function nearestEffort(wanted: string, supported: string[]): string | null { - const rank = (e: string) => - (CODEX_EFFORT_ORDER as readonly string[]).indexOf(e); - const known = supported - .filter((e) => rank(e) >= 0) - .sort((a, b) => rank(a) - rank(b)); - if (known.length === 0) return null; - const below = known.filter((e) => rank(e) <= rank(wanted)); - return below.length > 0 ? below[below.length - 1] : known[0]; -} - -function planCodexReasoning( - reasoning: CompletionParams["reasoning"], - slug: string, -): CodexReasoningWire { - if (!reasoning) return null; - const dialect = readCodexDialect(slug); - if (dialect.disabled) return null; - let effort: string | null = wantedEffort(reasoning.effort); - if (dialect.efforts && !dialect.efforts.includes(effort)) { - effort = nearestEffort(effort, dialect.efforts); - } - if (!effort) return null; - return { - effort, - summary: reasoning.summaries && effort !== "none" && !dialect.noSummary, - }; -} - -/** - * True when the request asks for the encrypted reasoning items, the only - * reasoning state a `store: false` request can hand to the next round. - */ -function asksEncryptedReasoning(wire: CodexReasoningWire): boolean { - return wire !== null && wire.effort !== "none"; -} - -/** - * Read a 400 that refuses a reasoning parameter and learn what the slug - * accepts instead. Returns the lesser wire to retry with, or `undefined` - * when the body is not about reasoning (the caller's error path takes it). - */ -function learnFromReasoningRejection( - slug: string, - wire: Exclude, - status: number, - body: string, -): CodexReasoningWire | undefined { - // Only a 400 that refuses the reasoning parameter counts; a 400 about the - // prompt or a tool, and a replay error about a reasoning item handed back - // from an earlier round, must not teach the slug anything. - if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) { - return undefined; - } - const dialect = readCodexDialect(slug); - let next: CodexReasoningWire; - if (wire.summary && /reasoning\.summary|summary/i.test(body)) { - dialect.noSummary = true; - next = { effort: wire.effort, summary: false }; - } else { - const listed = /supported values are:([^"\n]*)/i.exec(body); - const efforts = listed - ? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1]) - : []; - const nearest = - efforts.length > 0 && !efforts.includes(wire.effort) - ? nearestEffort(wire.effort, efforts) - : null; - if (nearest) { - dialect.efforts = efforts; - next = { effort: nearest, summary: wire.summary && nearest !== "none" }; - } else { - dialect.disabled = true; - next = null; - } - } - rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect)); - annotateReasoningDowngrade( - "codex", - wire.effort, - next ? next.effort : "default", - ); - return next; -} - /** * Map tool defs into the Responses `function` tool shape. * diff --git a/src/lib/ai/codex-reasoning-wire.ts b/src/lib/ai/codex-reasoning-wire.ts new file mode 100644 index 000000000..126740ce0 --- /dev/null +++ b/src/lib/ai/codex-reasoning-wire.ts @@ -0,0 +1,152 @@ +/** + * The reasoning half of the Codex wire: what `reasoning` field a request + * sends for a requested level, and what a slug that refuses it is asked + * instead. A slug's refusals are learned per slug in the shared dialect cache + * (`reasoning/dialect-cache.ts`), so the next request does not pay the + * refused round-trip again. + */ +import { + annotateReasoningDowngrade, + isReasoningParameterRejection, + learnedReasoningDialect, + rememberReasoningDialect, +} from "./reasoning/dialect-cache"; +import type { ReasoningLevel } from "./reasoning/levels"; +import type { CompletionParams } from "./types"; + +/** The responses endpoint, and the key its learned dialects are kept under. */ +export const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; + +/** + * The Responses-API efforts in ascending order. The Codex backend names the + * values a slug accepts in its 400 body ("Supported values are: 'none', + * 'low', …"), which is how a slug's ladder is learned. + */ +const CODEX_EFFORT_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; + +/** What one request sends: `null` is today's `reasoning: null`. */ +export type CodexReasoningWire = { effort: string; summary: boolean } | null; + +/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */ +interface CodexReasoningDialect { + /** The slug refused `reasoning` altogether. */ + disabled?: true; + /** The slug refused `reasoning.summary`. */ + noSummary?: true; + /** The efforts the slug named as supported when it refused one. */ + efforts?: string[]; +} + +function dialectKey(slug: string) { + return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug }; +} + +function readCodexDialect(slug: string): CodexReasoningDialect { + const raw = learnedReasoningDialect(dialectKey(slug)); + if (!raw) return {}; + try { + return JSON.parse(raw) as CodexReasoningDialect; + } catch { + return {}; + } +} + +function wantedEffort(level: ReasoningLevel): string { + // "Off" is a real off on the current slugs: a 2026-10-06 probe answered + // `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that + // refuses it learns its lowest supported value instead. + return level === "off" ? "none" : level; +} + +/** The supported effort closest to `wanted` from below, else the lowest. */ +function nearestEffort(wanted: string, supported: string[]): string | null { + const rank = (e: string) => + (CODEX_EFFORT_ORDER as readonly string[]).indexOf(e); + const known = supported + .filter((e) => rank(e) >= 0) + .sort((a, b) => rank(a) - rank(b)); + if (known.length === 0) return null; + const below = known.filter((e) => rank(e) <= rank(wanted)); + return below.length > 0 ? below[below.length - 1] : known[0]; +} + +export function planCodexReasoning( + reasoning: CompletionParams["reasoning"], + slug: string, +): CodexReasoningWire { + if (!reasoning) return null; + const dialect = readCodexDialect(slug); + if (dialect.disabled) return null; + let effort: string | null = wantedEffort(reasoning.effort); + if (dialect.efforts && !dialect.efforts.includes(effort)) { + effort = nearestEffort(effort, dialect.efforts); + } + if (!effort) return null; + return { + effort, + summary: reasoning.summaries && effort !== "none" && !dialect.noSummary, + }; +} + +/** + * True when the request asks for the encrypted reasoning items, the only + * reasoning state a `store: false` request can hand to the next round. + */ +export function asksEncryptedReasoning(wire: CodexReasoningWire): boolean { + return wire !== null && wire.effort !== "none"; +} + +/** + * Read a 400 that refuses a reasoning parameter and learn what the slug + * accepts instead. Returns the lesser wire to retry with, or `undefined` + * when the body is not about reasoning (the caller's error path takes it). + */ +export function learnFromReasoningRejection( + slug: string, + wire: Exclude, + status: number, + body: string, +): CodexReasoningWire | undefined { + // Only a 400 that refuses the reasoning parameter counts; a 400 about the + // prompt or a tool, and a replay error about a reasoning item handed back + // from an earlier round, must not teach the slug anything. + if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) { + return undefined; + } + const dialect = readCodexDialect(slug); + let next: CodexReasoningWire; + if (wire.summary && /reasoning\.summary|summary/i.test(body)) { + dialect.noSummary = true; + next = { effort: wire.effort, summary: false }; + } else { + const listed = /supported values are:([^"\n]*)/i.exec(body); + const efforts = listed + ? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1]) + : []; + const nearest = + efforts.length > 0 && !efforts.includes(wire.effort) + ? nearestEffort(wire.effort, efforts) + : null; + if (nearest) { + dialect.efforts = efforts; + next = { effort: nearest, summary: wire.summary && nearest !== "none" }; + } else { + dialect.disabled = true; + next = null; + } + } + rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect)); + annotateReasoningDowngrade( + "codex", + wire.effort, + next ? next.effort : "default", + ); + return next; +} From 973bb8e5fccc398f68c354d79a77f32df6534ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:11:02 +0100 Subject: [PATCH 03/25] feat(backup): rewrite a backup file's owner as it streams A backup names its owner once, in the top-level userId, and the restore writes every section under that account and no other. Pointing the copy of an erased account at another account is therefore one member, not a walk over every section. This changes that member on the bytes' way in, without holding the document, and reports the owner the file declared. Nested userId values and every other byte pass through unchanged. --- .../__tests__/backup-owner-rewrite.test.ts | 116 +++++++++++ src/lib/export/backup-owner-rewrite.ts | 184 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 src/lib/export/__tests__/backup-owner-rewrite.test.ts create mode 100644 src/lib/export/backup-owner-rewrite.ts diff --git a/src/lib/export/__tests__/backup-owner-rewrite.test.ts b/src/lib/export/__tests__/backup-owner-rewrite.test.ts new file mode 100644 index 000000000..f087f890d --- /dev/null +++ b/src/lib/export/__tests__/backup-owner-rewrite.test.ts @@ -0,0 +1,116 @@ +/** + * `rewriteBackupOwner` must change the top-level `userId` and nothing else, + * over any chunk boundaries: the output parses to the input with that one + * member replaced, and is byte-identical to it everywhere outside the value. + */ +import { describe, expect, it } from "vitest"; + +import { BackupJsonError } from "../backup-json-scan"; +import { + BackupOwnerRewriteError, + rewriteBackupOwner, +} from "../backup-owner-rewrite"; + +async function* chunked(text: string, sizes: number[]) { + const bytes = Buffer.from(text, "utf8"); + let at = 0; + let i = 0; + while (at < bytes.length) { + const size = sizes[i++ % sizes.length]!; + yield bytes.subarray(at, at + size); + at += size; + } +} + +async function run(text: string, target: string, sizes: number[] = [7]) { + const rewrite = rewriteBackupOwner(chunked(text, sizes), target); + const parts: Buffer[] = []; + for await (const part of rewrite.bytes) parts.push(part); + return { + text: Buffer.concat(parts).toString("utf8"), + declared: rewrite.declaredOwner(), + }; +} + +const NESTED = { + schemaVersion: "2", + exportedAt: "2026-10-01T00:00:00.000Z", + userId: "cerased0000000000000000", + measurements: [ + { id: "m1", userId: "cerased0000000000000000", note: 'a "userId": x' }, + ], + profile: { userId: "cerased0000000000000000", name: "ä ö ü 🫀 } ] , :" }, + notes: ["userId", "brackets ] } [ {"], +}; + +describe("rewriteBackupOwner", () => { + it("replaces only the top-level owner, at every chunk size", async () => { + const text = JSON.stringify(NESTED, null, 1); + for (const sizes of [[1], [2], [3], [5, 1, 9], [64], [text.length]]) { + const out = await run(text, "ctarget0000000000000000", sizes); + expect(out.declared).toBe("cerased0000000000000000"); + expect(JSON.parse(out.text)).toEqual({ + ...NESTED, + userId: "ctarget0000000000000000", + }); + // Outside the value, the bytes are the input's. + expect(out.text).toBe( + text.replace( + '"userId": "cerased0000000000000000"', + '"userId": "ctarget0000000000000000"', + ), + ); + } + }); + + it("reads an escaped spelling of the key as the key", async () => { + const text = '{"user\\u0049d":"cold","a":[1,{"userId":"keep"}]}'; + const out = await run(text, "cnew", [1]); + expect(out.declared).toBe("cold"); + expect(JSON.parse(out.text)).toEqual({ + userId: "cnew", + a: [1, { userId: "keep" }], + }); + }); + + it("replaces every top-level owner and declares the last, as JSON.parse keeps it", async () => { + const text = '{"userId":"first","x":1,"userId":"second"}'; + const out = await run(text, "cnew", [4]); + expect(out.declared).toBe(JSON.parse(text).userId); + expect(out.text).toBe('{"userId":"cnew","x":1,"userId":"cnew"}'); + }); + + it("leaves a file without an owner unchanged and declares none", async () => { + const text = '{"schemaVersion":"2","rows":[{"userId":"nested"}]}'; + const out = await run(text, "cnew", [3]); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("leaves a value that is a string elsewhere, or not a string, alone", async () => { + const text = '{"name":"userId","userId":42}'; + const out = await run(text, "cnew", [2]); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("leaves a top-level array alone", async () => { + const text = '["userId","x"]'; + const out = await run(text, "cnew"); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("refuses an owner too long to be an id", async () => { + const text = JSON.stringify({ userId: "x".repeat(4096) }); + await expect(run(text, "cnew", [100])).rejects.toBeInstanceOf( + BackupOwnerRewriteError, + ); + }); + + it("refuses a top-level key that is not valid JSON", async () => { + await expect(run('{"bad\\q":1}', "cnew")).rejects.toBeInstanceOf( + BackupJsonError, + ); + }); +}); diff --git a/src/lib/export/backup-owner-rewrite.ts b/src/lib/export/backup-owner-rewrite.ts new file mode 100644 index 000000000..2c94d0a3a --- /dev/null +++ b/src/lib/export/backup-owner-rewrite.ts @@ -0,0 +1,184 @@ +/** + * Point a backup file at another account, on its way in. + * + * A backup names its owner once, in the top-level `userId` member. The restore + * takes the account it writes from there and from nowhere else + * (`checkOwnership` in `restore-preflight.ts`), and every domain writes its + * rows under that account (`ctx.ownerId`), never under an id read from a row. + * So the copy of an account that has since been erased goes into another + * account by changing that one member, and nothing else in the file: the copy + * stored is then that account's copy in every respect (its preview, its + * download, its restore), and the erased id is stored nowhere, not even inside + * the encrypted copy. + * + * This rewrites the member as the bytes stream past, without holding the + * document (a disaster-recovery file can be longer than any string V8 can + * hold, see `backup-json-scan.ts`). It tracks only string and nesting state, + * and parses only the top-level keys and the one value it replaces. A key is + * compared after `JSON.parse`, so an escaped spelling of `userId` is the same + * key, exactly as the reader behind it sees it; a `userId` nested anywhere + * below the top level is left alone. Every other byte passes through + * unchanged. + */ + +import { BackupJsonError } from "@/lib/export/backup-json-scan"; + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; +const OPEN_BRACE = 0x7b; +const CLOSE_BRACE = 0x7d; +const OPEN_BRACKET = 0x5b; +const CLOSE_BRACKET = 0x5d; +const COMMA = 0x2c; + +/** Longer than any top-level key a backup carries. */ +const MAX_KEY_BYTES = 256; +/** Far longer than any account id; a longer owner is not an id. */ +const MAX_OWNER_BYTES = 1024; + +/** The file's owner member is not something this can rewrite. */ +export class BackupOwnerRewriteError extends Error { + constructor(message: string) { + super(message); + this.name = "BackupOwnerRewriteError"; + } +} + +type StringRole = "key" | "owner" | "other"; + +export interface BackupOwnerRewrite { + /** The bytes, with every top-level `userId` string set to the target. */ + bytes: AsyncGenerator; + /** + * The owner the file declared, once the stream has passed it: the last + * top-level `userId` string, which is the one `JSON.parse` keeps. Null when + * the file has none. + */ + declaredOwner(): string | null; +} + +export function rewriteBackupOwner( + source: AsyncIterable, + targetId: string, +): BackupOwnerRewrite { + const replacement = Buffer.from(JSON.stringify(targetId), "utf8"); + let declared: string | null = null; + + async function* bytes(): AsyncGenerator { + let depth = 0; + let topIsObject = false; + let expectKey = false; + let lastKey: string | null = null; + let inString = false; + let escaped = false; + let role: StringRole = "other"; + // The key or owner string being collected, across chunks. + let collected: Buffer[] = []; + let collectedBytes = 0; + + for await (const raw of source) { + const chunk = Buffer.isBuffer(raw) + ? raw + : Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength); + const out: Buffer[] = []; + // Where the bytes passed through unchanged start in this chunk; -1 + // while the owner value is being withheld. + let passFrom = role === "owner" && inString ? -1 : 0; + // Where the string being collected starts in this chunk. + let collectFrom = 0; + + const collect = (to: number) => { + if (to > collectFrom) { + collected.push(chunk.subarray(collectFrom, to)); + collectedBytes += to - collectFrom; + } + if (role === "key" && collectedBytes > MAX_KEY_BYTES) { + // Not a key this rewrite cares about; stop collecting it. + role = "other"; + collected = []; + collectedBytes = 0; + } + if (role === "owner" && collectedBytes > MAX_OWNER_BYTES) { + throw new BackupOwnerRewriteError( + "The file's owner is too long to be an account id", + ); + } + }; + const take = (): string => { + const text = Buffer.concat(collected, collectedBytes).toString("utf8"); + collected = []; + collectedBytes = 0; + try { + return JSON.parse(text) as string; + } catch { + throw new BackupJsonError( + "A top-level string in the file is not valid JSON", + ); + } + }; + + for (let i = 0; i < chunk.length; i++) { + const byte = chunk[i]!; + if (inString) { + if (escaped) escaped = false; + else if (byte === BACKSLASH) escaped = true; + else if (byte === QUOTE) { + inString = false; + if (role === "key") { + collect(i + 1); + lastKey = role === "key" ? take() : null; + expectKey = false; + } else if (role === "owner") { + collect(i + 1); + declared = take(); + out.push(replacement); + passFrom = i + 1; + } + role = "other"; + } + continue; + } + if (byte === QUOTE) { + inString = true; + if (depth === 1 && topIsObject && expectKey) { + role = "key"; + collectFrom = i; + } else if (depth === 1 && topIsObject && lastKey === "userId") { + role = "owner"; + collectFrom = i; + if (passFrom >= 0) out.push(chunk.subarray(passFrom, i)); + passFrom = -1; + } else { + role = "other"; + } + continue; + } + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) { + depth += 1; + if (depth === 1) { + topIsObject = byte === OPEN_BRACE; + expectKey = topIsObject; + } + continue; + } + if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) { + depth -= 1; + continue; + } + if (byte === COMMA && depth === 1) { + expectKey = topIsObject; + lastKey = null; + } + } + + if (inString && (role === "key" || role === "owner")) { + collect(chunk.length); + } + if (passFrom >= 0) out.push(chunk.subarray(passFrom)); + const piece = out.length === 1 ? out[0]! : Buffer.concat(out); + if (piece.byteLength > 0) yield piece; + } + } + + return { bytes: bytes(), declaredOwner: () => declared }; +} From 9baeb95babbb684e68c2524c08ac2a7a07f2873f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:11:08 +0100 Subject: [PATCH 04/25] feat(backup): say whether an account can take an erased account's copy A restore replaces the account's records and has no merge, so the copy of an account that no longer exists may only go into an account that holds none of the records a backup carries. The check reads every backed-up model by its userId, reaches the owner-less children through their parent, and is held to the schema so a new model cannot be read as empty. Managed profiles and accounts pending deletion are refused as well. The admin console asks GET /api/admin/backups/targets/{userId} before it sends a file again, so a refusal costs no upload. --- .../openapi-route-coverage-guard.test.ts | 4 + .../admin/backups/targets/[userId]/route.ts | 59 +++++++ .../__tests__/restore-target-models.test.ts | 45 +++++ src/lib/export/restore-target.ts | 158 ++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 src/app/api/admin/backups/targets/[userId]/route.ts create mode 100644 src/lib/export/__tests__/restore-target-models.test.ts create mode 100644 src/lib/export/restore-target.ts diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index bee50ca55..bafc8d560 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -225,6 +225,10 @@ const UNPUBLISHED: Readonly> = { // newer schema) an operator has to be able to look up the same way. See // `src/lib/openapi/routes/admin-backups.ts`. "/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] }, + "/api/admin/backups/targets/{userId}": { + kind: "adminConsole", + methods: ["GET"], + }, "/api/admin/central-codex": { kind: "adminConsole", methods: ["DELETE", "GET"], diff --git a/src/app/api/admin/backups/targets/[userId]/route.ts b/src/app/api/admin/backups/targets/[userId]/route.ts new file mode 100644 index 000000000..e66ac2eb3 --- /dev/null +++ b/src/app/api/admin/backups/targets/[userId]/route.ts @@ -0,0 +1,59 @@ +/** + * GET /api/admin/backups/targets/{userId} — whether the copy of an erased + * account may be restored into this account. + * + * The backups console asks before it sends a file a second time with that + * account as its target, so a refusal costs no upload. The upload decides + * again on its own (`checkRestoreTarget` in `restore-target.ts`); this answer + * is advice, not a reservation. It says no for a managed profile, an account + * pending deletion, and an account that already holds records, naming the + * models that hold them. Counts and record content are not read. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { checkRestoreTarget } from "@/lib/export/restore-target"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +const paramsSchema = z.object({ userId: z.string().min(1).max(64) }); + +export const GET = apiHandler( + async ( + _request: NextRequest, + { params }: { params: Promise<{ userId: string }> }, + ) => { + const { user: admin } = await requireAdmin(); + annotate({ action: { name: "admin.backups.target.check" } }); + + const rl = await checkRateLimit( + `admin-backups-target:${admin.id}`, + 30, + 60 * 1000, + ); + if (!rl.allowed) { + throw new HttpError(429, "Too many account checks"); + } + + const parsed = paramsSchema.safeParse(await params); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + const verdict = await checkRestoreTarget(prisma, parsed.data.userId); + if (!verdict.ok && verdict.reason === "not_found") { + throw new HttpError(404, "User not found"); + } + const target = verdict.target!; + return apiSuccess({ + id: target.id, + username: target.username, + eligible: verdict.ok, + reason: verdict.ok ? null : verdict.reason, + models: verdict.ok ? [] : verdict.models, + }); + }, +); diff --git a/src/lib/export/__tests__/restore-target-models.test.ts b/src/lib/export/__tests__/restore-target-models.test.ts new file mode 100644 index 000000000..f6bfffaaf --- /dev/null +++ b/src/lib/export/__tests__/restore-target-models.test.ts @@ -0,0 +1,45 @@ +/** + * The emptiness check behind restoring an erased account's copy into another + * account reads every backed-up model by its `userId`, except the children + * that have none, which it reaches through their parent. Both halves are held + * to `prisma/schema.prisma` here, so a backed-up model added later is either + * checked or fails this file; it cannot be skipped and read as empty. + * + * Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first + * case names it (no `userId`); add an owned model to it and the second does. + */ +import { describe, expect, it } from "vitest"; + +import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; +import { + BACKED_UP_CHILD_MODELS, + RESTORE_TARGET_OWNED_MODELS, +} from "@/lib/export/restore-target"; + +const models = parseWipeSchema(); + +describe("restore target models", () => { + it("reads every owned backed-up model by its userId", () => { + const missing = RESTORE_TARGET_OWNED_MODELS.filter( + (model) => !models.get(model)?.scalars.includes("userId"), + ); + expect(missing).toEqual([]); + expect(RESTORE_TARGET_OWNED_MODELS.length).toBeGreaterThan(60); + }); + + it("reaches each child through a required cascade to an owned parent", () => { + for (const [child, parent] of Object.entries(BACKED_UP_CHILD_MODELS)) { + expect(BACKED_UP_MODELS, child).toContain(child); + const shape = models.get(child); + expect(shape?.scalars, child).not.toContain("userId"); + expect( + shape?.relations.some( + (r) => r.target === parent && r.cascade && !r.optional, + ), + `${child} -> ${parent}`, + ).toBe(true); + expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent); + } + }); +}); diff --git a/src/lib/export/restore-target.ts b/src/lib/export/restore-target.ts new file mode 100644 index 000000000..9f371d94b --- /dev/null +++ b/src/lib/export/restore-target.ts @@ -0,0 +1,158 @@ +/** + * Which account the copy of an erased account may be restored into. + * + * A restore replaces: every row of the account in a section the copy carries + * is deleted and the copy's rows are written in their place + * (`docs/ops/backup-restore.md`). There is no merge. So putting the record of + * an account that no longer exists into another account is only safe when + * that account holds none of those rows, and the check is exactly that: a row + * of any model the backup carries ({@link BACKED_UP_MODELS}) under the + * account refuses it. Everything a restore leaves alone (credentials, + * sessions, devices, grants) does not count. + * + * The other two refusals keep the account rules intact. A managed profile is + * its Guardians' record, which an admin does not write into (the users list + * offers no admin deletion on one for the same reason). An account pending + * deletion is locked and will be erased, with whatever was restored into it. + * Neither the restore nor this check touches the role, so the last-admin rule + * is not in play. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; + +/** + * The backed-up models with no owner column of their own, each with the owned + * model it hangs off through a required cascade. A row of one cannot exist + * without a row of its parent, so checking the parent checks it. + * `restore-target-models.test.ts` holds this list to the schema. + */ +export const BACKED_UP_CHILD_MODELS: Readonly> = { + MedicationSchedule: "Medication", + MedicationScheduleRevision: "Medication", + MedicationDoseChange: "Medication", + MedicationEfficacyTarget: "Medication", + MedicationInventoryEvent: "Medication", + MedicationCategoryAssignment: "Medication", + ReminderPhaseConfig: "Medication", + MoodEntryTagLink: "MoodEntry", + IllnessSymptomLink: "IllnessDayLog", + CycleSymptomLink: "CycleDayLog", + WorkoutRoute: "Workout", + WorkoutSamples: "Workout", + CoachMessage: "CoachConversation", + CoachConversationDocument: "CoachConversation", +}; + +/** Every backed-up model that carries `userId`, in the plan's order. */ +export const RESTORE_TARGET_OWNED_MODELS: readonly string[] = + BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS)); + +interface OwnedDelegate { + findFirst(args: { + where: { userId: string }; + select: { userId: true }; + }): Promise; +} + +function ownedDelegate(db: object, model: string): OwnedDelegate { + const key = model.charAt(0).toLowerCase() + model.slice(1); + const delegate = (db as Record)[key]; + if (!delegate || typeof delegate.findFirst !== "function") { + // Never narrower than the plan: a model this cannot read is a model it + // would otherwise call empty. + throw new Error(`No Prisma delegate for backed-up model "${model}"`); + } + return delegate; +} + +/** The backed-up models under which `userId` holds at least one row. */ +export async function modelsHoldingRecords( + db: Prisma.TransactionClient, + userId: string, +): Promise { + const held: string[] = []; + for (const model of RESTORE_TARGET_OWNED_MODELS) { + const row = await ownedDelegate(db, model).findFirst({ + where: { userId }, + select: { userId: true }, + }); + if (row) held.push(model); + } + return held; +} + +export type RestoreTargetRefusal = + "not_found" | "managed_profile" | "pending_deletion" | "holds_records"; + +export type RestoreTargetVerdict = + | { ok: true; target: { id: string; username: string } } + | { + ok: false; + reason: RestoreTargetRefusal; + target: { id: string; username: string } | null; + /** For `holds_records`: the models that hold rows, in plan order. */ + models: string[]; + }; + +/** Whether the copy of an erased account may go into `userId`. */ +export async function checkRestoreTarget( + db: Prisma.TransactionClient, + userId: string, +): Promise { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + managedProfileAt: true, + deletionRequestedAt: true, + }, + }); + if (!row) { + return { ok: false, reason: "not_found", target: null, models: [] }; + } + const target = { id: row.id, username: row.username }; + if (row.managedProfileAt) { + return { ok: false, reason: "managed_profile", target, models: [] }; + } + if (row.deletionRequestedAt) { + return { ok: false, reason: "pending_deletion", target, models: [] }; + } + const models = await modelsHoldingRecords(db, row.id); + if (models.length > 0) { + return { ok: false, reason: "holds_records", target, models }; + } + return { ok: true, target }; +} + +/** The status and stable code each refusal answers with. */ +export const RESTORE_TARGET_REFUSALS: Readonly< + Record< + RestoreTargetRefusal, + { status: number; code: string; message: string } + > +> = { + not_found: { + status: 404, + code: "backup.target.not_found", + message: "The chosen account does not exist.", + }, + managed_profile: { + status: 409, + code: "backup.target.managed_profile", + message: + "The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.", + }, + pending_deletion: { + status: 409, + code: "backup.target.pending_deletion", + message: + "The chosen account is pending deletion. Undo the deletion first, or choose another account.", + }, + holds_records: { + status: 409, + code: "backup.target.holds_records", + message: + "The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.", + }, +}; From 8014fd4aae845878e78913acc1cd62c4a71cc5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:11:14 +0100 Subject: [PATCH 05/25] feat(backup): restore an erased account's copy into a chosen account After an account is erased the operator still has its disaster-recovery file, and the upload refused it because its owner was gone. The refusal now carries backup.owner.missing, and the file can be sent again with a target in the query: an existing account that holds no records yet, confirmed by typing its username, or a new account created for the copy. The file's owner is rewritten to that account as it is read, so the stored copy, its preview, its download and its restore are that account's, and the restore that follows is the ordinary one. The new account has no password and no email; an admin sets its first password with the existing reset. A file whose own account is still here is refused with a target, and an account made for a refused file is removed again. The refusal of an unknown owner no longer writes the owner's id into the audit trail, since the erasure took every row that named it. --- docs/api/openapi.yaml | 71 ++- src/app/api/admin/backups/upload/route.ts | 205 ++++++++- src/lib/openapi/routes/admin-backups.ts | 33 +- src/lib/validations/backup-upload-target.ts | 70 +++ .../backup-erased-account-restore.test.ts | 432 +++++++++++++++++- 5 files changed, 786 insertions(+), 25 deletions(-) create mode 100644 src/lib/validations/backup-upload-target.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f31a99c8e..84ae02ca4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25090,10 +25090,52 @@ paths: same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account - that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens - cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST - /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it - the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." + that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists + here (erased, say) can be sent again with a target in the query: `into=existing&userId=&confirm=` + for an account that holds no records yet, or `into=new&username=` for a new account created for the copy, + with no password and no email (an admin sets its first password with the existing password reset). The file's + owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like + any other; the erased account's id is not stored. A target is refused before the file is read when the account + does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does + not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three + uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A + browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last + five minutes (with a second factor, on an account that has one). Without it the answer is 401 + `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." + parameters: + - in: query + name: into + schema: + description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account, + which has to exist." + type: string + enum: + - existing + - new + description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account, + which has to exist." + - in: query + name: userId + schema: + description: "With `into=existing`: the account's id." + type: string + maxLength: 64 + description: "With `into=existing`: the account's id." + - in: query + name: confirm + schema: + description: "With `into=existing`: the account's username, typed by the operator." + type: string + maxLength: 64 + description: "With `into=existing`: the account's username, typed by the operator." + - in: query + name: username + schema: + description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)." + type: string + minLength: 3 + maxLength: 30 + description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)." requestBody: required: true content: @@ -25139,6 +25181,22 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a25 + "404": + description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The chosen target cannot take the copy, and nothing was stored: a managed profile + (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an + account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username + already taken (`backup.target.username_taken`), or a file whose own account is still here + (`backup.owner.exists`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = @@ -25150,8 +25208,9 @@ paths: "422": description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not - hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist - here." + hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here + (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose + `confirm` is not the account's username (`backup.target.unconfirmed`)." content: application/json: schema: diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index c37642213..4f9f2bd41 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -20,6 +20,17 @@ * checked as it goes and stored in the same pass; a file that fails any check * rolls the store back, so nothing of it remains. * + * The file names the account it belongs to, and that account has to exist + * here. When it does not (the account was erased, and the operator still has + * its disaster-recovery file), the upload answers 422 with + * `backup.owner.missing`, and the operator can send the file again with a + * target in the query (`backup-upload-target.ts`): an existing account that + * holds no records yet, confirmed by typing its username, or a new account + * created for the copy. The file's owner is then rewritten to that account as + * the bytes come in (`backup-owner-rewrite.ts`), so the copy stored is that + * account's copy, and the restore that follows is the ordinary one. The new + * account has no password; an admin sets its first one in Admin, Users. + * * Crucially, this route does NOT execute a restore. It only stores the * file. The separate `POST /api/admin/backups/[id]/restore` endpoint * (criterion 3) actually replaces user data — keeping the two phases @@ -32,6 +43,7 @@ import { Readable } from "node:stream"; import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload"; import { NextRequest } from "next/server"; +import { Prisma } from "@/generated/prisma/client"; import { ZodError } from "zod/v4"; import { prisma } from "@/lib/db"; import { @@ -40,9 +52,19 @@ import { requireAdmin, assertRecentCookieProof, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { BackupJsonError } from "@/lib/export/backup-json-scan"; +import { + BackupOwnerRewriteError, + rewriteBackupOwner, + type BackupOwnerRewrite, +} from "@/lib/export/backup-owner-rewrite"; import { BackupUploadDecodeError, decodeBackupUpload, @@ -57,6 +79,11 @@ import { BACKUP_UPLOAD_TOO_LARGE_CODE, BackupBlobTooLargeError, } from "@/lib/export/backup-blob"; +import { + checkRestoreTarget, + RESTORE_TARGET_REFUSALS, + type RestoreTargetVerdict, +} from "@/lib/export/restore-target"; import { storeBackupBlob } from "@/lib/export/store-backup-blob"; import { readStreamedBackup, @@ -66,6 +93,11 @@ import { import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import type { BackupPayload } from "@/lib/validations/backup"; +import { + BACKUP_OWNER_EXISTS_CODE, + BACKUP_OWNER_MISSING_CODE, + parseBackupUploadTarget, +} from "@/lib/validations/backup-upload-target"; import { isCompatibleSchemaVersion, summarizeBackup, @@ -111,6 +143,21 @@ class UploadRefused extends Error { } } +/** The answer and audit details for a target the copy may not go into. */ +function targetRefused( + verdict: Extract, + userId: string, +): UploadRefused { + const refusal = RESTORE_TARGET_REFUSALS[verdict.reason]; + const models = verdict.models.length > 0 ? { models: verdict.models } : {}; + return new UploadRefused( + refusal.status, + refusal.message, + { reason: `target_${verdict.reason}`, targetUserId: userId, ...models }, + { errorCode: refusal.code, ...models }, + ); +} + /** The file's bytes, gunzipped and counted on both ends. */ async function* decodedBytes( source: AsyncIterable, @@ -129,6 +176,26 @@ async function* decodedBytes( } } +/** + * Delete the account an upload created for its copy, when nothing was stored. + * It holds nothing and nobody can have signed into it (no password, no email), + * so the row is all there is. A failure is noted, never thrown over the + * refusal the operator needs to see. + */ +async function discardCreatedAccount(userId: string): Promise { + try { + await prisma.user.delete({ where: { id: userId } }); + } catch (err) { + annotate({ + meta: { + backup_upload_account_discard_failed: true, + backup_upload_account_discard_error: + err instanceof Error ? err.message : String(err), + }, + }); + } +} + export const POST = apiHandler(async (request: NextRequest) => { const { user: admin, session } = await requireAdmin(); // A fresh proof on top of the admin session: this reaches every account's @@ -155,6 +222,49 @@ export const POST = apiHandler(async (request: NextRequest) => { details: { reason, ...extra }, }); + // The account the copy goes into when the file's own account is gone. + // Decided before a byte of the file is read, so a target that will be + // refused costs no upload; checked again once the file has been read. + const parsedTarget = parseBackupUploadTarget( + new URL(request.url).searchParams, + ); + if (!parsedTarget.success) { + await denied("target_invalid"); + return returnAllZodIssues(parsedTarget.error); + } + const choice = parsedTarget.data; + let target: { id: string; username: string; created: boolean } | null = null; + if (choice?.into === "existing") { + const verdict = await checkRestoreTarget(prisma, choice.userId); + if (!verdict.ok) { + const refusal = targetRefused(verdict, choice.userId); + await denied(refusal.details.reason as string, refusal.details); + return apiError(refusal.answer, refusal.status, refusal.meta); + } + // The restore writes into this account, so the operator names it. + if (choice.confirm.trim() !== verdict.target.username) { + await denied("target_unconfirmed", { targetUserId: choice.userId }); + return apiError( + "Type the chosen account's username to confirm. Nothing was stored.", + 422, + { errorCode: "backup.target.unconfirmed" }, + ); + } + target = { ...verdict.target, created: false }; + } else if (choice?.into === "new") { + const taken = await prisma.user.findUnique({ + where: { username: choice.username }, + select: { id: true }, + }); + if (taken) { + await denied("target_username_taken"); + return apiError("That username is already taken.", 409, { + errorCode: "backup.target.username_taken", + }); + } + } + annotate({ meta: { backup_upload_into: choice?.into ?? "owner" } }); + const contentType = request.headers.get("content-type") ?? ""; const multipart = contentType.startsWith("multipart/form-data"); const limit = multipart ? MAX_UPLOAD_BYTES : MAX_RAW_UPLOAD_BYTES; @@ -226,6 +336,32 @@ export const POST = apiHandler(async (request: NextRequest) => { // answers with and the key uses it already holds (`backup-preview.ts`). let keys: BackupKeyIdCollector | undefined; let created: { id: string }; + let rewrite: BackupOwnerRewrite | null = null; + if (choice?.into === "new") { + // Created before the file is read, because the file is rewritten to the + // account's id on its way in; deleted again below when nothing is + // stored. No password, no email: the account cannot be signed into + // until an admin sets its first password (Admin, Users). + try { + const account = await prisma.user.create({ + data: { username: choice.username, role: "USER" }, + select: { id: true, username: true }, + }); + target = { ...account, created: true }; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + await denied("target_username_taken"); + return apiError("That username is already taken.", 409, { + errorCode: "backup.target.username_taken", + }); + } + throw err; + } + } + const chosen = target; try { created = await storeBackupBlob( prisma, @@ -247,8 +383,10 @@ export const POST = apiHandler(async (request: NextRequest) => { async (write) => { // One pass: every chunk is stored as it is checked, and a check that // fails throws, which rolls the store back. + const decoded = decodedBytes(source, limit); + if (chosen) rewrite = rewriteBackupOwner(decoded, chosen.id); async function* storedAsRead() { - for await (const chunk of decodedBytes(source, limit)) { + for await (const chunk of rewrite ? rewrite.bytes : decoded) { await write(Buffer.from(chunk)); yield chunk; } @@ -269,6 +407,15 @@ export const POST = apiHandler(async (request: NextRequest) => { message: err.message, }); } + if (err instanceof BackupOwnerRewriteError) { + const issues = [{ path: "userId", message: err.message }]; + throw new UploadRefused( + 422, + "Backup payload failed schema validation", + { reason: "schema_invalid", issues }, + { issues }, + ); + } if (err instanceof StreamedBackupInvalidError) { const issues = [{ path: err.path, message: err.message }]; throw new UploadRefused( @@ -335,6 +482,34 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + if (chosen) { + // The file now names the chosen account. The account it named + // before has to be gone: a copy of an account that is still here + // restores into that account, never into another one. + const declared = rewrite?.declaredOwner() ?? null; + const declaredOwner = declared + ? await prisma.user.findUnique({ + where: { id: declared }, + select: { id: true }, + }) + : null; + if (!declared || declaredOwner) { + throw new UploadRefused( + 409, + "This file belongs to an account on this server and restores into that account. Upload it without choosing an account.", + { reason: "owner_exists", ownerId: declared }, + { errorCode: BACKUP_OWNER_EXISTS_CODE }, + ); + } + // The file took a while to read; the account may have changed. + if (!chosen.created) { + const again = await checkRestoreTarget(prisma, chosen.id); + if (!again.ok) throw targetRefused(again, chosen.id); + } + owner = { id: chosen.id, username: chosen.username }; + return; + } + // Make sure the userId on the file points at a user that exists in // this DB. Restore would fail later anyway, but failing here gives // the admin a precise error before any side-effect. @@ -343,15 +518,22 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { id: true, username: true }, }); if (!owner) { + // Most often an account that was erased. Its id is not written + // into the trail: the erasure took every row that named it, and a + // refused upload is not a reason to bring it back. The answer says + // what the operator can do instead. throw new UploadRefused( 422, - `Backup is for user '${payload.userId}' which does not exist in this DB`, - { reason: "owner_not_found", ownerId: payload.userId }, + "This file belongs to an account that does not exist on this server. Choose an account to restore it into.", + { reason: "owner_not_found" }, + { errorCode: BACKUP_OWNER_MISSING_CODE }, ); } }, ); } catch (err) { + // Nothing was stored, so the account made for the copy goes again. + if (chosen?.created) await discardCreatedAccount(chosen.id); if (err instanceof UploadRefused) { await denied(err.details.reason as string, err.details); return apiError(err.answer, err.status, err.meta); @@ -380,6 +562,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }; const storedOwner = owner as { id: string; username: string } | null; + if (chosen?.created) { + await auditLog("admin.user.create", { + userId: admin.id, + ipAddress, + details: { + targetUserId: chosen.id, + username: chosen.username, + reason: "backup_restore", + backupId: created.id, + }, + }); + } await auditLog("admin.backups.upload", { userId: admin.id, ipAddress, @@ -387,6 +581,9 @@ export const POST = apiHandler(async (request: NextRequest) => { backupId: created.id, ownerId: storedOwner?.id, ownerUsername: storedOwner?.username, + // Set when the file's own account was gone and the copy was pointed at + // this one. The erased account's id is not recorded. + ...(chosen ? { into: chosen.created ? "new" : "existing" } : {}), type: uploadType, schemaVersion: summary.schemaVersion, counts: { diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index f997b382a..cc149e2c4 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -218,7 +218,26 @@ export const adminBackupPaths: NonNullable = { tags: ["Admin"], summary: "Store an uploaded backup file as a new copy", description: - "Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.", + "Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists here (erased, say) can be sent again with a target in the query: `into=existing&userId=&confirm=` for an account that holds no records yet, or `into=new&username=` for a new account created for the copy, with no password and no email (an admin sets its first password with the existing password reset). The file's owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like any other; the erased account's id is not stored. A target is refused before the file is read when the account does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.", + requestParams: { + query: z.object({ + into: z.enum(["existing", "new"]).optional().meta({ + description: + "Where the copy goes when the account the file names does not exist here. Absent: the file's own account, which has to exist.", + }), + userId: z.string().max(64).optional().meta({ + description: "With `into=existing`: the account's id.", + }), + confirm: z.string().max(64).optional().meta({ + description: + "With `into=existing`: the account's username, typed by the operator.", + }), + username: z.string().min(3).max(30).optional().meta({ + description: + "With `into=new`: the new account's username (letters, digits, `_` and `-`).", + }), + }), + }, requestBody: { required: true, content: { @@ -261,6 +280,16 @@ export const adminBackupPaths: NonNullable = { "No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.", content: { "application/json": { schema: errorEnvelope } }, }, + "404": { + description: + "The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.", @@ -268,7 +297,7 @@ export const adminBackupPaths: NonNullable = { }, "422": { description: - "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.", + "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose `confirm` is not the account's username (`backup.target.unconfirmed`).", content: { "application/json": { schema: errorEnvelope } }, }, "429": { diff --git a/src/lib/validations/backup-upload-target.ts b/src/lib/validations/backup-upload-target.ts new file mode 100644 index 000000000..a28e95e6e --- /dev/null +++ b/src/lib/validations/backup-upload-target.ts @@ -0,0 +1,70 @@ +/** + * Where an uploaded backup goes when the account it names no longer exists. + * + * The upload's body is the file itself, so the choice rides in the query: + * + * - `into=existing&userId=&confirm=`: an existing account, + * confirmed by typing its username, since the restore writes into it; + * - `into=new&username=`: a new account created for the copy. + * + * No query at all is the upload as it always was: the copy goes to the + * account the file names, which has to exist. + */ +import { z } from "zod/v4"; + +import { accountUsernameSchema } from "@/lib/validations/auth"; + +/** The upload's refusal when the file's own account is not on this server. */ +export const BACKUP_OWNER_MISSING_CODE = "backup.owner.missing"; +/** The upload's refusal when a target was chosen for a file whose account exists. */ +export const BACKUP_OWNER_EXISTS_CODE = "backup.owner.exists"; + +export const backupUploadTargetSchema = z.discriminatedUnion("into", [ + z + .object({ + into: z.literal("existing"), + userId: z.string().min(1).max(64), + confirm: z.string().max(64), + }) + .strict(), + z + .object({ + into: z.literal("new"), + username: accountUsernameSchema, + }) + .strict(), +]); + +export type BackupUploadTarget = z.infer; + +const TARGET_PARAMS = ["into", "userId", "confirm", "username"] as const; + +/** + * Read the target from the upload's query. Null when the query names none of + * its parameters; a query that names any of them is held to the schema whole. + */ +export function parseBackupUploadTarget( + params: URLSearchParams, +): + | { success: true; data: BackupUploadTarget | null } + | { success: false; error: z.ZodError } { + if (!TARGET_PARAMS.some((name) => params.has(name))) { + return { success: true, data: null }; + } + const parsed = backupUploadTargetSchema.safeParse( + Object.fromEntries(params.entries()), + ); + return parsed.success + ? { success: true, data: parsed.data } + : { success: false, error: parsed.error }; +} + +/** The query string for a target, as the admin console sends it. */ +export function backupUploadTargetQuery(target: BackupUploadTarget): string { + const params = new URLSearchParams( + target.into === "existing" + ? { into: "existing", userId: target.userId, confirm: target.confirm } + : { into: "new", username: target.username }, + ); + return params.toString(); +} diff --git a/tests/integration/backup-erased-account-restore.test.ts b/tests/integration/backup-erased-account-restore.test.ts index 6a507a70a..39f6afc56 100644 --- a/tests/integration/backup-erased-account-restore.test.ts +++ b/tests/integration/backup-erased-account-restore.test.ts @@ -9,24 +9,43 @@ * session, and a server that no longer knows the account. * * 1. The purge leaves nothing in the database that names the account. - * 2. The admin upload refuses the file, because its owner is gone, and - * stores nothing (the documented contract in `docs/ops/backup-restore.md`: - * a restore writes into an account that exists under the same id). - * 3. With the account row put back under its original id, the one way that - * contract leaves, the upload and the restore bring every section back. + * 2. The admin upload without a target refuses the file, because its owner + * is gone, says so with `backup.owner.missing`, stores nothing, and does + * not write the erased id into the trail either. + * 3. With the account row put back under its original id, the upload and + * the restore bring every section back: the same-id path, unchanged. * 4. Into a freshly created account, with the file's owner set to that - * account, the restore brings every section back and nothing in the - * database names the erased account afterwards. + * account by hand, the restore brings every section back and nothing in + * the database names the erased account afterwards. This is the + * mechanism the upload's target generalises. + * 5. The upload with a target does it for the operator: into an existing + * empty account (typed confirmation) and into a new account it creates, + * every section comes back, the audit trail says what happened, and + * nothing names the erased account, the refused first upload included. + * 6. A target that holds records, is a managed profile, is pending deletion + * or is not confirmed is refused before the file is read; so is a target + * for a file whose own account is still here, and a new account made for + * a refused file is removed again. A non-admin session and a Bearer + * token are refused outright. * * Everything runs through production code: the deletion route, the purge job, - * the admin upload and restore routes and the restore job + * the admin upload, target-check and restore routes and the restore job * (`restore-job-driver.ts` runs the queued job in-process). */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@/generated/prisma/client"; +import { hashToken } from "@/lib/auth/hmac"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; +import { + openStoredBackup, + STORED_BACKUP_SELECT, +} from "@/lib/export/stored-backup"; +import { + backupUploadTargetQuery, + type BackupUploadTarget, +} from "@/lib/validations/backup-upload-target"; import { OWNER_ID, @@ -207,10 +226,10 @@ async function eraseAfterExport(prisma: PrismaClient) { return { admin, json, counts }; } -async function upload(body: string) { +async function upload(body: string, query = "") { const { POST } = await import("@/app/api/admin/backups/upload/route"); const res = await POST( - new Request("http://localhost/api/admin/backups/upload", { + new Request(`http://localhost/api/admin/backups/upload${query}`, { method: "POST", headers: { "content-type": "application/json" }, body, @@ -221,7 +240,7 @@ async function upload(body: string) { body: (await res.json()) as { data: { id: string } | null; error: string | null; - meta?: { reason?: string; errorCode?: string }; + meta?: { reason?: string; errorCode?: string; models?: string[] }; }, }; } @@ -245,6 +264,52 @@ async function restore(backupId: string) { }; } +/** The upload, with a target in the query as the backups console sends it. */ +function uploadInto(body: string, target: BackupUploadTarget) { + return upload(body, `?${backupUploadTargetQuery(target)}`); +} + +async function checkTarget(userId: string) { + const { GET } = + await import("@/app/api/admin/backups/targets/[userId]/route"); + const { NextRequest } = await import("next/server"); + const res = await GET( + new NextRequest(`http://localhost/api/admin/backups/targets/${userId}`), + { params: Promise.resolve({ userId }) }, + ); + return { + status: res.status, + body: (await res.json()) as { + data: { + id: string; + eligible: boolean; + reason: string | null; + models: string[]; + } | null; + }, + }; +} + +/** The stored copy as the restore will read it, as text. */ +async function storedCopyText(prisma: PrismaClient, backupId: string) { + const row = await prisma.dataBackup.findUniqueOrThrow({ + where: { id: backupId }, + select: STORED_BACKUP_SELECT, + }); + const source = await openStoredBackup(prisma, row); + const parts: Buffer[] = []; + for await (const part of source()) parts.push(Buffer.from(part)); + return { row, text: Buffer.concat(parts).toString("utf8") }; +} + +async function auditDetails(prisma: PrismaClient, action: string) { + const rows = await prisma.auditLog.findMany({ + where: { action }, + orderBy: { createdAt: "asc" }, + }); + return rows.map((row) => JSON.parse(row.details ?? "{}") as object); +} + async function countsOf(prisma: PrismaClient, userId: string) { const { counts } = await buildFullBackupPayload(prisma, userId, { purpose: "disaster-recovery", @@ -266,14 +331,16 @@ describe("restoring the backup of an erased account", () => { const refused = await upload(json); expect(refused.status, JSON.stringify(refused.body)).toBe(422); + expect(refused.body.meta?.errorCode).toBe("backup.owner.missing"); const denial = await prisma.auditLog.findFirstOrThrow({ where: { action: "admin.backups.upload.denied" }, }); - expect(JSON.parse(denial.details ?? "{}")).toMatchObject({ + expect(JSON.parse(denial.details ?? "{}")).toEqual({ reason: "owner_not_found", - ownerId: OWNER_ID, }); expect(await prisma.dataBackup.count()).toBe(0); + // The refusal wrote a row, and that row does not bring the id back. + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); }, SLOW, ); @@ -324,3 +391,342 @@ describe("restoring the backup of an erased account", () => { SLOW, ); }); + +describe("restoring the backup of an erased account into a chosen account", () => { + it( + "into an existing empty account: every section, the trail, and nothing naming the erased one", + async () => { + const prisma = getPrismaClient(); + const { json, counts } = await eraseAfterExport(prisma); + const fresh = await prisma.user.create({ + data: { + username: "returning-person", + email: "returning-person@example.test", + }, + }); + + // The operator's first try, which the console turns into the choice. + const first = await upload(json); + expect(first.body.meta?.errorCode).toBe("backup.owner.missing"); + + const check = await checkTarget(fresh.id); + expect(check.status).toBe(200); + expect(check.body.data).toMatchObject({ + id: fresh.id, + eligible: true, + reason: null, + models: [], + }); + + const stored = await uploadInto(json, { + into: "existing", + userId: fresh.id, + confirm: "returning-person", + }); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const backupId = stored.body.data!.id; + + // The stored copy is the chosen account's copy, inside as well. + const copy = await storedCopyText(prisma, backupId); + expect(copy.row.userId).toBe(fresh.id); + expect(JSON.parse(copy.text).userId).toBe(fresh.id); + expect(copy.text.includes(OWNER_ID)).toBe(false); + + const restored = await restore(backupId); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + expect(restored.body.data!.skipped.links).toBe(0); + expect(await countsOf(prisma, fresh.id)).toEqual(counts); + + expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([ + expect.objectContaining({ + backupId, + ownerId: fresh.id, + ownerUsername: "returning-person", + into: "existing", + }), + ]); + expect(await auditDetails(prisma, "admin.backups.restore")).toEqual([ + expect.objectContaining({ backupId, ownerId: fresh.id }), + ]); + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + }, + SLOW, + ); + + it( + "into a new account: created without a password, filled, and signed into through the admin reset", + async () => { + const prisma = getPrismaClient(); + vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "1"); + const { admin, json, counts } = await eraseAfterExport(prisma); + const usersBefore = await prisma.user.count(); + + const stored = await uploadInto(json, { + into: "new", + username: "returning-person", + }); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const backupId = stored.body.data!.id; + expect(await prisma.user.count()).toBe(usersBefore + 1); + const account = await prisma.user.findUniqueOrThrow({ + where: { username: "returning-person" }, + }); + expect(account).toMatchObject({ + role: "USER", + email: null, + passwordHash: null, + managedProfileAt: null, + deletionRequestedAt: null, + }); + expect( + (await prisma.dataBackup.findUniqueOrThrow({ where: { id: backupId } })) + .userId, + ).toBe(account.id); + + const restored = await restore(backupId); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + expect(restored.body.data!.skipped.links).toBe(0); + expect(await countsOf(prisma, account.id)).toEqual(counts); + // Identity is never in a restore: still the account the upload made. + expect( + await prisma.user.findUniqueOrThrow({ + where: { id: account.id }, + select: { username: true, role: true, passwordHash: true }, + }), + ).toEqual({ + username: "returning-person", + role: "USER", + passwordHash: null, + }); + + expect(await auditDetails(prisma, "admin.user.create")).toEqual([ + { + targetUserId: account.id, + username: "returning-person", + reason: "backup_restore", + backupId, + }, + ]); + expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([ + expect.objectContaining({ ownerId: account.id, into: "new" }), + ]); + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + + // How the person gets in: the admin sets a first password with the + // existing reset, which is all the new account needs. + const { POST: resetPassword } = + await import("@/app/api/admin/users/[id]/reset-password/route"); + const { NextRequest } = await import("next/server"); + const reset = await resetPassword( + new NextRequest( + `http://localhost/api/admin/users/${account.id}/reset-password`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: "Harbour-lantern-47-quietly" }), + }, + ), + { params: Promise.resolve({ id: account.id }) }, + ); + expect(reset.status, await reset.clone().text()).toBe(200); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: account.id } })) + .passwordHash, + ).not.toBeNull(); + expect(admin.role).toBe("ADMIN"); + }, + SLOW, + ); + + it( + "refuses an account that holds records, a managed profile, an account pending deletion and a wrong confirmation, before reading the file", + async () => { + const prisma = getPrismaClient(); + const { json } = await eraseAfterExport(prisma); + const used = await prisma.user.create({ + data: { username: "in-use", email: "in-use@example.test" }, + }); + await prisma.measurement.create({ + data: { + userId: used.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-10-01T08:00:00.000Z"), + }, + }); + const managed = await prisma.user.create({ + data: { username: "managed-cafe01", managedProfileAt: new Date() }, + }); + const leaving = await prisma.user.create({ + data: { + username: "leaving", + deletionRequestedAt: new Date(), + deletionPurgeAt: new Date(Date.now() + 14 * DAY_MS), + }, + }); + const empty = await prisma.user.create({ data: { username: "empty" } }); + + expect((await checkTarget(used.id)).body.data).toMatchObject({ + eligible: false, + reason: "holds_records", + models: ["Measurement"], + }); + expect((await checkTarget("no-such-account")).status).toBe(404); + + const cases: Array<[BackupUploadTarget, number, string]> = [ + [ + { into: "existing", userId: used.id, confirm: "in-use" }, + 409, + "backup.target.holds_records", + ], + [ + { into: "existing", userId: managed.id, confirm: "managed-cafe01" }, + 409, + "backup.target.managed_profile", + ], + [ + { into: "existing", userId: leaving.id, confirm: "leaving" }, + 409, + "backup.target.pending_deletion", + ], + [ + { into: "existing", userId: "no-such-account", confirm: "x" }, + 404, + "backup.target.not_found", + ], + [ + { into: "existing", userId: empty.id, confirm: "Empty" }, + 422, + "backup.target.unconfirmed", + ], + [ + { into: "new", username: "in-use" }, + 409, + "backup.target.username_taken", + ], + ]; + for (const [target, status, code] of cases) { + await prisma.rateLimit.deleteMany(); + const refused = await uploadInto(json, target); + expect(refused.status, JSON.stringify(refused.body)).toBe(status); + expect(refused.body.meta?.errorCode).toBe(code); + } + + expect(await prisma.dataBackup.count()).toBe(0); + expect( + await prisma.measurement.count({ where: { userId: used.id } }), + ).toBe(1); + expect( + (await auditDetails(prisma, "admin.backups.upload.denied")).map( + (details) => (details as { reason: string }).reason, + ), + ).toEqual([ + "target_holds_records", + "target_managed_profile", + "target_pending_deletion", + "target_not_found", + "target_unconfirmed", + "target_username_taken", + ]); + + // A query that does not parse is refused with every issue. + await prisma.rateLimit.deleteMany(); + const malformed = await upload(json, "?into=new&username=no%20spaces"); + expect(malformed.status).toBe(422); + expect( + await prisma.user.count({ where: { username: "no spaces" } }), + ).toBe(0); + }, + SLOW, + ); + + it( + "refuses a target for a file whose own account is still here, and removes an account made for it", + async () => { + const prisma = getPrismaClient(); + const { json } = await eraseAfterExport(prisma); + await createOwner(prisma); + const empty = await prisma.user.create({ data: { username: "empty" } }); + const usersBefore = await prisma.user.count(); + + const existing = await uploadInto(json, { + into: "existing", + userId: empty.id, + confirm: "empty", + }); + expect(existing.status, JSON.stringify(existing.body)).toBe(409); + expect(existing.body.meta?.errorCode).toBe("backup.owner.exists"); + + await prisma.rateLimit.deleteMany(); + const fresh = await uploadInto(json, { + into: "new", + username: "would-be-copy", + }); + expect(fresh.status, JSON.stringify(fresh.body)).toBe(409); + expect(fresh.body.meta?.errorCode).toBe("backup.owner.exists"); + expect(await prisma.user.count()).toBe(usersBefore); + expect( + await prisma.user.count({ where: { username: "would-be-copy" } }), + ).toBe(0); + expect(await auditDetails(prisma, "admin.user.create")).toEqual([]); + expect(await prisma.dataBackup.count()).toBe(0); + + // Without a target, the same file goes to its own account as before. + await prisma.rateLimit.deleteMany(); + const own = await upload(json); + expect(own.status, JSON.stringify(own.body)).toBe(201); + expect( + ( + await prisma.dataBackup.findUniqueOrThrow({ + where: { id: own.body.data!.id }, + }) + ).userId, + ).toBe(OWNER_ID); + }, + SLOW, + ); + + it( + "refuses a non-admin session and an admin's Bearer token, and creates nothing", + async () => { + const prisma = getPrismaClient(); + const { admin, json } = await eraseAfterExport(prisma); + const member = await prisma.user.create({ data: { username: "member" } }); + const empty = await prisma.user.create({ data: { username: "empty" } }); + const usersBefore = await prisma.user.count(); + + cookieJar.clear(); + await signIn(prisma, member.id); + const asMember = await uploadInto(json, { + into: "new", + username: "member-made", + }); + expect(asMember.status).toBe(403); + expect((await checkTarget(empty.id)).status).toBe(403); + + cookieJar.clear(); + const raw = `hlk_erasedrestore_${"0".repeat(48)}`; + await prisma.apiToken.create({ + data: { + userId: admin.id, + name: "wildcard", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + const asBearer = await uploadInto(json, { + into: "existing", + userId: empty.id, + confirm: "empty", + }); + expect(asBearer.status).toBe(401); + expect((await checkTarget(empty.id)).status).toBe(401); + + expect(await prisma.user.count()).toBe(usersBefore); + expect(await prisma.dataBackup.count()).toBe(0); + }, + SLOW, + ); +}); From 5ab1fb782e1f5207cb931b45590969039ecb9717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:17:42 +0100 Subject: [PATCH 06/25] feat(ai): pick Codex models from the account's live list, per sign-in A fixed preference list goes stale at every retirement: when GPT-5.5 retired for ChatGPT sign-in on 2026-10-14, every model the shipped list named was refused and Codex answered 503 until a release changed it. The working model also sat in one slot for the whole process, so two sign-ins on different plans overwrote each other's model and the vision gate read whichever had answered last. The client now reads the models the account is offered the way the Codex CLI does (GET /backend-api/codex/models?client_version=..., same sign-in as the responses call; shape from codex-rs/protocol/src/openai_models.rs) and walks: CODEX_MODEL first; our preference order kept to what is offered; the other offered models in the list's priority order, leaving out hidden, specialist and frontier-tier entries; the rest of our order last, so the walk never holds fewer models than before. With CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is not read. The list is a hint, never a gate. The read goes through safeFetch with a 5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per sign-in for an hour, and a failed read waits ten minutes. Any failure leaves the static walk, one ai.codex.models_unreadable warning per sign-in reports it, and each request that builds the walk annotates a pinned codex.models.* shape. The working model, refused models and the list are kept per stored credential (a person's connection, or the operator-shared central one), bounded by TTL and a 1000-entry LRU, with a digest of the account id so a credential reconnected to another account starts empty. A model the list offered that then answers model_not_found goes to the back of that sign-in's line for six hours. When the list names a model's input modalities, the Codex vision gate reads them; otherwise the model family decides, as before. Refs #1287 --- .../baselines/version-stamp-comments.json | 7 +- .../ai/__tests__/cached-input-usage.test.ts | 1 + src/lib/ai/__tests__/codex-client.test.ts | 24 + .../ai/__tests__/codex-live-models.test.ts | 319 ++++++ .../ai/__tests__/codex-model-catalog.test.ts | 374 ++++++ src/lib/ai/__tests__/codex-reasoning.test.ts | 2 + src/lib/ai/__tests__/codex-slug-cache.test.ts | 158 ++- src/lib/ai/__tests__/codex-slug-chain.test.ts | 315 +++++ .../ai/__tests__/codex-slug-fallback.test.ts | 54 +- src/lib/ai/__tests__/codex-timeout.test.ts | 1 + .../fixtures/codex/models-response.json | 1012 +++++++++++++++++ .../ai/__tests__/provider-contract.test.ts | 6 + .../response-timeout-binding.test.ts | 1 + .../tools-loop-final-round-wire.test.ts | 1 + src/lib/ai/codex-client.ts | 102 +- src/lib/ai/codex-model-catalog.ts | 297 +++++ src/lib/ai/codex-slug-cache.ts | 220 +++- src/lib/ai/codex-slug-chain.ts | 264 ++++- src/lib/ai/provider.ts | 28 +- src/lib/labs/__tests__/ocr-capability.test.ts | 3 - .../labs/__tests__/ocr-output-budget.test.ts | 3 + src/lib/labs/ocr-capability.ts | 28 +- 22 files changed, 3012 insertions(+), 208 deletions(-) create mode 100644 src/lib/ai/__tests__/codex-live-models.test.ts create mode 100644 src/lib/ai/__tests__/codex-model-catalog.test.ts create mode 100644 src/lib/ai/__tests__/codex-slug-chain.test.ts create mode 100644 src/lib/ai/__tests__/fixtures/codex/models-response.json create mode 100644 src/lib/ai/codex-model-catalog.ts diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2e5febe0e..8ad581d44 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2896, - "total": 9228, + "count": 2895, + "total": 9226, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1741,8 +1741,7 @@ "lib/ai/coach/turn/types.ts": 17, "lib/ai/coach/types.ts": 58, "lib/ai/coach/visits-snapshot.ts": 1, - "lib/ai/codex-client.ts": 18, - "lib/ai/codex-slug-cache.ts": 1, + "lib/ai/codex-client.ts": 17, "lib/ai/consent-guard.ts": 1, "lib/ai/feedback-attribution.ts": 2, "lib/ai/insight-interpretation.ts": 1, diff --git a/src/lib/ai/__tests__/cached-input-usage.test.ts b/src/lib/ai/__tests__/cached-input-usage.test.ts index 2a555aad5..46a71ec06 100644 --- a/src/lib/ai/__tests__/cached-input-usage.test.ts +++ b/src/lib/ai/__tests__/cached-input-usage.test.ts @@ -122,6 +122,7 @@ function jsonResponse(body: unknown) { function codexClient() { return new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-client.test.ts b/src/lib/ai/__tests__/codex-client.test.ts index 86ab4fd9e..ea7a84011 100644 --- a/src/lib/ai/__tests__/codex-client.test.ts +++ b/src/lib/ai/__tests__/codex-client.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { CodexClient } from "../codex-client"; import { singleUserTurn } from "../types"; +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; + /** * Build a `Response` whose `.body` streams a list of SSE event strings. * Mirrors the wire format the Codex backend emits (each event terminated @@ -51,6 +55,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -97,6 +103,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -126,6 +134,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "expired-token", accountId: "acct-test", onTokenRefresh: async () => { @@ -156,6 +166,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "bad", accountId: "acct-test", onTokenRefresh: async () => { @@ -183,6 +195,8 @@ describe("CodexClient", () => { const onRefresh = vi.fn(); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test", accountId: "acct-test", onTokenRefresh: async () => { @@ -208,6 +222,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -245,6 +261,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -292,6 +310,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -349,6 +369,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -393,6 +415,8 @@ describe("CodexClient", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-live-models.test.ts b/src/lib/ai/__tests__/codex-live-models.test.ts new file mode 100644 index 000000000..9e310c50a --- /dev/null +++ b/src/lib/ai/__tests__/codex-live-models.test.ts @@ -0,0 +1,319 @@ +/** + * The Codex client end to end against a stubbed backend: the account's model + * list read the way the Codex CLI reads it, the walk built from it, and the + * working model kept per sign-in (#1287). + * + * Every request goes through the real `safeFetch` into a stubbed `fetch` + * that answers `/codex/models` and `/codex/responses` per account, so the + * list read, the cache around it and the walk are all the production code. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CodexClient } from "../codex-client"; +import { singleUserTurn } from "../types"; +import { + CODEX_SLUG_CACHE_TTL_MS, + clearCodexSlugCache, + codexCredentialKey, + getCachedCodexSlug, +} from "../codex-slug-cache"; + +const FIXTURE = JSON.parse( + readFileSync(join(__dirname, "fixtures/codex/models-response.json"), "utf8"), +) as { models: Array<{ slug: string }> }; + +/** The fixture cut to `slugs`, as a plan that offers only those would see it. */ +function planList(...slugs: string[]): string { + return JSON.stringify({ + models: FIXTURE.models.filter((m) => slugs.includes(m.slug)), + }); +} + +function sse(text = '{"ok":true}'): Response { + const body = [ + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { usage: { total_tokens: 3 } }, + })}\n\n`, + ].join(""); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function retired(slug: string): Response { + return new Response( + JSON.stringify({ + detail: `The '${slug}' model is not supported when using Codex with a ChatGPT account.`, + error: { type: "invalid_request_error", code: "model_not_found" }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +interface Account { + /** The `/models` answer, or a function standing in for a failure. */ + models: string | (() => Response | Promise); + /** The models the responses endpoint accepts for this account. */ + accepts: string[]; +} + +/** + * A backend with one entry per ChatGPT account id. Records every request as + * ` models` or ` responses:`. + */ +function stubBackend(accounts: Record) { + const log: string[] = []; + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + const headers = init.headers as Record; + const account = accounts[headers["ChatGPT-Account-ID"]]; + if (!account) return new Response("unknown account", { status: 401 }); + const name = headers["ChatGPT-Account-ID"]; + if (url.includes("/codex/models")) { + log.push(`${name} models`); + return typeof account.models === "string" + ? new Response(account.models, { status: 200 }) + : account.models(); + } + const slug = JSON.parse(String(init.body)).model as string; + log.push(`${name} responses:${slug}`); + return account.accepts.includes(slug) ? sse() : retired(slug); + }); + vi.stubGlobal("fetch", fetchMock); + return log; +} + +function client(userKey: string, accountId: string) { + return new CodexClient({ + accessToken: `token-${accountId}`, + accountId, + credentialKey: userKey, + onTokenRefresh: vi.fn(), + }); +} + +const ask = (c: CodexClient) => + c.generateCompletion(singleUserTurn({ system: "s", user: "u" })); + +const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + clearCodexSlugCache(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("Codex reads the account's live model list", () => { + it("goes straight to an offered model after a retirement the built-in order does not know about", async () => { + // The 2026-10-14 shape: nothing on the built-in order's head is offered. + const log = stubBackend({ + plus: { + models: planList("gpt-6.1-sol", "gpt-6-luna"), + accepts: ["gpt-6.1-sol", "gpt-6-luna"], + }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + expect(log).toEqual(["plus models", "plus responses:gpt-6.1-sol"]); + expect(c.getLastDiagnostics()?.attempted).toEqual(["gpt-6.1-sol"]); + }); + + it("does not read the list again while the working model holds", async () => { + const log = stubBackend({ + plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-sol"] }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + await ask(c); + await ask(c); + expect(log.filter((l) => l.endsWith("models"))).toHaveLength(1); + expect(c.getLastDiagnostics()?.cacheState).toBe("hit"); + }); + + it("a models call that fails still reaches a working model through the static walk", async () => { + for (const failure of [ + () => new Response("bad gateway", { status: 502 }), + () => new Response("", { status: 200 }), + () => new Response('{"models":[]}', { status: 200 }), + () => { + throw new TypeError("fetch failed"); + }, + ]) { + clearCodexSlugCache(); + const log = stubBackend({ + plus: { models: failure, accepts: ["gpt-6-sol"] }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + // Today's walk: terra refused, gpt-6-sol answers. + expect(log).toEqual([ + "plus models", + "plus responses:gpt-5.6-terra", + "plus responses:gpt-6-sol", + ]); + } + }); + + it("a models call that never answers is cut off and the walk goes on", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const log = stubBackend({ + plus: { + // Resolves only when the 5 s bound aborts it. + models: () => + new Promise((_, reject) => + setTimeout( + () => + reject( + new DOMException("The operation timed out.", "TimeoutError"), + ), + 5_000, + ), + ), + accepts: ["gpt-5.6-terra"], + }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + const answer = ask(c); + await vi.advanceTimersByTimeAsync(5_000); + await answer; + expect(log).toEqual(["plus models", "plus responses:gpt-5.6-terra"]); + }); + + it("an explicit CODEX_MODEL still goes first, ahead of the list", async () => { + process.env.CODEX_MODEL = "gpt-6-astra"; + const log = stubBackend({ + plus: { + models: planList("gpt-6-astra", "gpt-6-sol"), + accepts: ["gpt-6-astra", "gpt-6-sol"], + }, + }); + await ask(client(codexCredentialKey.user("u1"), "plus")); + expect(log).toEqual(["plus models", "plus responses:gpt-6-astra"]); + }); + + it("CODEX_MODEL_FALLBACK_CHAIN replaces the order and the list is not read", async () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = "gpt-6-luna"; + const log = stubBackend({ + plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-luna"] }, + }); + await ask(client(codexCredentialKey.user("u1"), "plus")); + expect(log).toEqual(["plus responses:gpt-6-luna"]); + }); +}); + +describe("Codex keeps the working model per sign-in", () => { + it("two sign-ins on different plans do not overwrite each other", async () => { + const log = stubBackend({ + // One plan still has terra; the other only the gpt-6 line. + pro: { + models: planList("gpt-5.6-terra", "gpt-6-sol"), + accepts: ["gpt-5.6-terra", "gpt-6-sol"], + }, + edu: { + models: planList("gpt-6.1-sol", "gpt-6-luna"), + accepts: ["gpt-6.1-sol", "gpt-6-luna"], + }, + }); + const a = client(codexCredentialKey.user("a"), "pro"); + const b = client(codexCredentialKey.user("b"), "edu"); + const central = client(codexCredentialKey.central, "edu"); + + await ask(a); + await ask(b); + await ask(central); + await ask(a); + await ask(b); + + expect(getCachedCodexSlug(codexCredentialKey.user("a"))).toBe( + "gpt-5.6-terra", + ); + expect(getCachedCodexSlug(codexCredentialKey.user("b"))).toBe( + "gpt-6.1-sol", + ); + expect(getCachedCodexSlug(codexCredentialKey.central)).toBe("gpt-6.1-sol"); + // Not one refused request: each sign-in only ever asked what it is offered. + expect(log.filter((l) => l.includes("responses"))).toEqual([ + "pro responses:gpt-5.6-terra", + "edu responses:gpt-6.1-sol", + "edu responses:gpt-6.1-sol", + "pro responses:gpt-5.6-terra", + "edu responses:gpt-6.1-sol", + ]); + }); + + it("a listed model that answers model_not_found is skipped for that sign-in only", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const both = planList("gpt-5.6-terra", "gpt-6-sol"); + const log = stubBackend({ + // The list still offers terra, but the backend already retired it. + stale: { models: both, accepts: ["gpt-6-sol"] }, + fresh: { models: both, accepts: ["gpt-5.6-terra", "gpt-6-sol"] }, + }); + const stale = client(codexCredentialKey.user("s"), "stale"); + const fresh = client(codexCredentialKey.user("f"), "fresh"); + + await ask(stale); + await ask(fresh); + expect(log).toEqual([ + "stale models", + "stale responses:gpt-5.6-terra", + "stale responses:gpt-6-sol", + "fresh models", + "fresh responses:gpt-5.6-terra", + ]); + + // Past the working model's TTL the stale sign-in walks again, and terra + // is at the back of its line even though the list still offers it. + log.length = 0; + vi.setSystemTime(Date.now() + CODEX_SLUG_CACHE_TTL_MS + 1); + await ask(stale); + expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]); + }); + + it("the working model that stops answering is dropped and the walk goes on", async () => { + const accepts = ["gpt-5.6-terra", "gpt-6-sol"]; + const log = stubBackend({ + plus: { models: planList("gpt-5.6-terra", "gpt-6-sol"), accepts }, + }); + const c = client(codexCredentialKey.user("u1"), "plus"); + await ask(c); + expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBe( + "gpt-5.6-terra", + ); + + // terra retires between two requests. + accepts.splice(0, 1); + log.length = 0; + await ask(c); + expect(log).toEqual([ + "plus responses:gpt-5.6-terra", + "plus responses:gpt-6-sol", + ]); + expect(c.getLastDiagnostics()).toMatchObject({ + cacheState: "hit", + attempted: ["gpt-5.6-terra", "gpt-6-sol"], + workingSlug: "gpt-6-sol", + }); + }); +}); diff --git a/src/lib/ai/__tests__/codex-model-catalog.test.ts b/src/lib/ai/__tests__/codex-model-catalog.test.ts new file mode 100644 index 000000000..e8893b50d --- /dev/null +++ b/src/lib/ai/__tests__/codex-model-catalog.test.ts @@ -0,0 +1,374 @@ +/** + * The account's Codex model list: parsing the `/models` body, the bounded + * read, and the per-sign-in cache around it. + * + * The fixture is the upstream catalog bundle (openai/codex + * `codex-rs/models-manager/models.json`) cut to the models a ChatGPT + * sign-in was offered on 2026-10-24, plus one hidden review model and one + * hidden specialist model, every field as upstream ships it except the + * instruction templates, which are trimmed. The live endpoint answers in the + * same `ModelsResponse` shape (`codex-rs/protocol/src/openai_models.rs`). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetch = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: (...a: unknown[]) => safeFetch(...a), +})); +const emitSignal = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/logging/signal", () => ({ + emitSignal: (...a: unknown[]) => emitSignal(...a), +})); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + CODEX_MODELS_CLIENT_VERSION, + cachedCodexModels, + fetchCodexModelList, + parseCodexModelList, + readCodexModelCatalog, +} from "../codex-model-catalog"; +import { + CODEX_CATALOG_RETRY_MS, + CODEX_CATALOG_TTL_MS, + clearCodexSlugCache, + codexCredentialKey, +} from "../codex-slug-cache"; + +const FIXTURE = readFileSync( + join(__dirname, "fixtures/codex/models-response.json"), + "utf8", +); + +const AUTH = { accessToken: "access-token", accountId: "acct-plus" }; +const PERSONAL = { key: codexCredentialKey.user("u1"), accountId: "acct-plus" }; + +function jsonResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + safeFetch.mockReset(); + emitSignal.mockReset(); + clearCodexSlugCache(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("parseCodexModelList", () => { + it("reads the upstream shape: slug, visibility, priority, modalities, specialty", () => { + const models = parseCodexModelList(JSON.parse(FIXTURE)); + expect(models?.map((m) => m.slug)).toEqual([ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-daybreak-blue-latest", + "codex-auto-review", + ]); + const luna = models?.find((m) => m.slug === "gpt-6-luna"); + expect(luna).toEqual({ + slug: "gpt-6-luna", + listed: true, + priority: 4, + inputModalities: ["text", "image"], + specialty: null, + }); + expect(models?.find((m) => m.slug === "codex-auto-review")?.listed).toBe( + false, + ); + expect( + models?.find((m) => m.slug === "gpt-daybreak-blue-latest")?.specialty, + ).toBe("cyber"); + }); + + it("refuses a body that is not the documented envelope", () => { + for (const body of [ + null, + "models", + 42, + [], + {}, + { models: "gpt-6-sol" }, + { data: [{ id: "gpt-6-sol" }] }, + ]) { + expect(parseCodexModelList(body), JSON.stringify(body)).toBeNull(); + } + }); + + it("skips entries without a usable slug and keeps the rest", () => { + const models = parseCodexModelList({ + models: [ + null, + "gpt-6-sol", + { slug: 7 }, + { slug: "" }, + { slug: "has spaces" }, + { slug: "gpt-6-sol\nInjected: header" }, + { slug: "x".repeat(200) }, + { slug: "gpt-6-luna", visibility: "list" }, + { slug: "gpt-6-luna", visibility: "list" }, + ], + }); + expect(models).toEqual([ + { + slug: "gpt-6-luna", + listed: true, + priority: null, + inputModalities: null, + specialty: null, + }, + ]); + }); + + it("reads missing or odd fields conservatively", () => { + const [model] = parseCodexModelList({ + models: [ + { + slug: "gpt-7-sol", + visibility: "LIST", + priority: "1", + input_modalities: "text", + }, + ], + })!; + // Not "list" exactly → not listed; a non-array modality field says nothing. + expect(model).toEqual({ + slug: "gpt-7-sol", + listed: false, + priority: null, + inputModalities: null, + specialty: null, + }); + }); + + it("returns an empty array for an empty list", () => { + expect(parseCodexModelList({ models: [] })).toEqual([]); + }); +}); + +describe("fetchCodexModelList", () => { + it("asks the endpoint the Codex CLI asks, with the responses call's sign-in", async () => { + safeFetch.mockResolvedValue(jsonResponse(FIXTURE)); + const result = await fetchCodexModelList(AUTH); + expect(result.ok).toBe(true); + const [url, init, options] = safeFetch.mock.calls[0]; + expect(url).toBe( + `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_MODELS_CLIENT_VERSION}`, + ); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer access-token"); + expect(init.headers["ChatGPT-Account-ID"]).toBe("acct-plus"); + expect(init.headers.originator).toBe("healthlog"); + // Bounded: the Codex CLI's own 5 s, and no redirect following. + expect(options).toEqual({ timeoutMs: 5_000 }); + }); + + it.each([ + ["a timeout", new SafeFetchError("timed out", "timeout"), "timeout"], + ["a network error", new SafeFetchError("reset", "network"), "network"], + ])("maps %s to its reason", async (_label, error, reason) => { + safeFetch.mockRejectedValue(error); + expect(await fetchCodexModelList(AUTH)).toEqual({ + ok: false, + reason, + status: null, + }); + }); + + it("maps refusals, bad bodies and empty lists to closed reasons", async () => { + const cases: Array<[Response, string]> = [ + [jsonResponse("unauthorized", 401), "auth"], + [jsonResponse("forbidden", 403), "auth"], + [jsonResponse("bad gateway", 502), "http_error"], + [new Response(null, { status: 302 }), "http_error"], + [jsonResponse("not json"), "unparsable"], + [jsonResponse('{"data":[]}'), "unparsable"], + [jsonResponse('{"models":[]}'), "empty"], + [jsonResponse('{"models":[{"slug":""}]}'), "empty"], + ]; + for (const [response, reason] of cases) { + safeFetch.mockResolvedValueOnce(response); + const result = await fetchCodexModelList(AUTH); + expect(result.ok, reason).toBe(false); + expect(!result.ok && result.reason, reason).toBe(reason); + } + }); + + it("stops reading a body past the size bound", async () => { + const huge = new Response( + new ReadableStream({ + start(controller) { + const chunk = new Uint8Array(1024 * 1024).fill(32); + for (let i = 0; i < 5; i += 1) controller.enqueue(chunk); + controller.close(); + }, + }), + { status: 200 }, + ); + safeFetch.mockResolvedValue(huge); + expect(await fetchCodexModelList(AUTH)).toEqual({ + ok: false, + reason: "too_large", + status: 200, + }); + }); + + it("reports a body that stalls past the timeout as a timeout", async () => { + const stalled = new Response( + new ReadableStream({ + pull() { + throw new DOMException("The operation timed out.", "TimeoutError"); + }, + }), + { status: 200 }, + ); + safeFetch.mockResolvedValue(stalled); + const result = await fetchCodexModelList(AUTH); + expect(!result.ok && result.reason).toBe("timeout"); + }); +}); + +describe("readCodexModelCatalog", () => { + it("reads once, then answers from the cache until the TTL passes", async () => { + vi.useFakeTimers(); + safeFetch.mockImplementation(async () => jsonResponse(FIXTURE)); + + const first = await readCodexModelCatalog(PERSONAL, AUTH); + expect(first.from).toBe("live"); + expect(first.models).toHaveLength(9); + const second = await readCodexModelCatalog(PERSONAL, AUTH); + expect(second.from).toBe("cache"); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + + vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); + expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live"); + expect(safeFetch).toHaveBeenCalledTimes(2); + }); + + it("shares one read between concurrent requests on a sign-in", async () => { + let release: (r: Response) => void = () => {}; + safeFetch.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const reads = [ + readCodexModelCatalog(PERSONAL, AUTH), + readCodexModelCatalog(PERSONAL, AUTH), + readCodexModelCatalog(PERSONAL, AUTH), + ]; + release(jsonResponse(FIXTURE)); + const results = await Promise.all(reads); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(results.every((r) => r.models?.length === 9)).toBe(true); + }); + + it("remembers a failed read for the retry window and signals it once", async () => { + vi.useFakeTimers(); + safeFetch.mockImplementation(async () => jsonResponse("down", 503)); + + const first = await readCodexModelCatalog(PERSONAL, AUTH); + expect(first).toEqual({ + models: null, + from: "live", + failure: "http_error", + }); + const again = await readCodexModelCatalog(PERSONAL, AUTH); + expect(again).toEqual({ + models: null, + from: "cache", + failure: "http_error", + }); + expect(safeFetch).toHaveBeenCalledTimes(1); + + // Past the retry window the list is asked again; still failing, the + // stretch has already been reported. + vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + expect(safeFetch).toHaveBeenCalledTimes(2); + expect(emitSignal).toHaveBeenCalledTimes(1); + expect(emitSignal).toHaveBeenCalledWith({ + action: "ai.codex.models_unreadable", + level: "warn", + meta: { connection: "personal", reason: "http_error", status: 503 }, + }); + }); + + it("signals again after the list answered in between", async () => { + vi.useFakeTimers(); + safeFetch + .mockResolvedValueOnce(jsonResponse("down", 503)) + .mockResolvedValueOnce(jsonResponse(FIXTURE)) + .mockResolvedValueOnce(new Response("{", { status: 200 })); + await readCodexModelCatalog(PERSONAL, AUTH); + vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); + await readCodexModelCatalog(PERSONAL, AUTH); + expect(emitSignal).toHaveBeenCalledTimes(2); + expect(emitSignal.mock.calls[1][0].meta.reason).toBe("unparsable"); + }); + + it("signals once per sign-in, naming which connection without naming whose", async () => { + safeFetch.mockImplementation(async () => { + throw new SafeFetchError("timed out", "timeout"); + }); + const central = { key: codexCredentialKey.central, accountId: "acct-op" }; + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" }); + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" }); + expect(emitSignal.mock.calls.map((c) => c[0].meta)).toEqual([ + { connection: "personal", reason: "timeout", status: null }, + { connection: "central", reason: "timeout", status: null }, + ]); + expect(JSON.stringify(emitSignal.mock.calls)).not.toContain("u1"); + }); + + it("does not remember an expired token, so the next request reads again", async () => { + safeFetch + .mockResolvedValueOnce(jsonResponse("unauthorized", 401)) + .mockResolvedValueOnce(jsonResponse(FIXTURE)); + const refused = await readCodexModelCatalog(PERSONAL, AUTH); + expect(refused.failure).toBe("auth"); + const next = await readCodexModelCatalog(PERSONAL, { + ...AUTH, + accessToken: "refreshed", + }); + expect(next.models).toHaveLength(9); + expect(emitSignal).not.toHaveBeenCalled(); + }); + + it("keeps each sign-in's list to itself", async () => { + const plus = JSON.parse(FIXTURE) as { models: { slug: string }[] }; + const other = { + models: plus.models.filter((m) => m.slug.startsWith("gpt-6")), + }; + safeFetch + .mockResolvedValueOnce(jsonResponse(FIXTURE)) + .mockResolvedValueOnce(jsonResponse(JSON.stringify(other))); + const second = { key: codexCredentialKey.user("u2"), accountId: "acct-b" }; + await readCodexModelCatalog(PERSONAL, AUTH); + await readCodexModelCatalog(second, { ...AUTH, accountId: "acct-b" }); + expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + expect(cachedCodexModels(second.key)?.map((m) => m.slug)).toEqual([ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + ]); + }); +}); diff --git a/src/lib/ai/__tests__/codex-reasoning.test.ts b/src/lib/ai/__tests__/codex-reasoning.test.ts index a4609130e..6a72e01f9 100644 --- a/src/lib/ai/__tests__/codex-reasoning.test.ts +++ b/src/lib/ai/__tests__/codex-reasoning.test.ts @@ -46,6 +46,7 @@ function errorResponse(status: number, body: string): Response { function client() { return new CodexClient({ + credentialKey: "user:test", accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi @@ -158,6 +159,7 @@ describe("Codex reasoning request", () => { ); vi.stubGlobal("fetch", mock); const c = new CodexClient({ + credentialKey: "user:test", accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/codex-slug-cache.test.ts b/src/lib/ai/__tests__/codex-slug-cache.test.ts index 94f414bf2..819b4277f 100644 --- a/src/lib/ai/__tests__/codex-slug-cache.test.ts +++ b/src/lib/ai/__tests__/codex-slug-cache.test.ts @@ -1,59 +1,147 @@ import { describe, it, expect, beforeEach } from "vitest"; import { - getCachedCodexSlug, - setCachedCodexSlug, - invalidateCachedCodexSlug, - clearCodexSlugCache, + CODEX_CATALOG_RETRY_MS, + CODEX_CATALOG_TTL_MS, + CODEX_CREDENTIAL_CACHE_MAX, + CODEX_REJECTED_SLUG_TTL_MS, CODEX_SLUG_CACHE_TTL_MS, + clearCodexSlugCache, + codexCredentialCacheSize, + codexCredentialKey, + getCachedCodexSlug, + getCodexCatalogState, + rejectedCodexSlugs, + rememberRejectedCodexSlug, + setCachedCodexSlug, + setCodexCatalogState, } from "../codex-slug-cache"; /** - * Slug-drift defence positive cache. - * Cache TTL = 1 hour per `docs/codex-protocol-spec.md` §7b. + * Per-sign-in memory: the working slug (1 h, spec §7b), refused slugs, and + * the account's model list, each kept under its stored credential. */ -describe("Codex slug cache", () => { +const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" }; +const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" }; +const CENTRAL = { key: codexCredentialKey.central, accountId: "acct-op" }; + +describe("Codex per-sign-in cache", () => { beforeEach(() => { clearCodexSlugCache(); }); + it("keys a person's connection and the central one apart", () => { + expect(codexCredentialKey.user("a")).toBe("user:a"); + expect(codexCredentialKey.central).toBe("central"); + }); + it("starts empty", () => { - expect(getCachedCodexSlug()).toBeNull(); + expect(getCachedCodexSlug(A.key)).toBeNull(); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(getCodexCatalogState(A.key)).toBeNull(); }); - it("set + get returns the cached slug within TTL", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - expect(getCachedCodexSlug(1_000)).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS - 1)).toBe( - "gpt-5.3-codex", + it("holds the working slug for exactly the TTL", () => { + setCachedCodexSlug(A, "gpt-6-sol", 1_000); + expect(getCachedCodexSlug(A.key, 1_000)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( + "gpt-6-sol", ); + expect( + getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), + ).toBeNull(); + expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000); }); - it("expires entries strictly after the TTL", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - // Exactly at TTL: still valid (the comparison is `now - cached > TTL`). - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( - "gpt-5.3-codex", + it("keeps every sign-in's working slug to itself", () => { + // Two plans: one account works on terra, the other only on gpt-6. + setCachedCodexSlug(A, "gpt-5.6-terra"); + setCachedCodexSlug(B, "gpt-6.1-sol"); + setCachedCodexSlug(CENTRAL, "gpt-6-sol"); + expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); + expect(getCachedCodexSlug(B.key)).toBe("gpt-6.1-sol"); + expect(getCachedCodexSlug(CENTRAL.key)).toBe("gpt-6-sol"); + + rememberRejectedCodexSlug(B, "gpt-6.1-sol"); + expect(getCachedCodexSlug(B.key)).toBeNull(); + expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + }); + + it("a refusal drops the working slot and is held for its own TTL", () => { + setCachedCodexSlug(A, "gpt-5.6-terra", 0); + rememberRejectedCodexSlug(A, "gpt-5.6-terra", 10); + expect(getCachedCodexSlug(A.key, 10)).toBeNull(); + expect([...rejectedCodexSlugs(A.key, 10)]).toEqual(["gpt-5.6-terra"]); + expect([ + ...rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS), + ]).toEqual(["gpt-5.6-terra"]); + expect( + rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size, + ).toBe(0); + }); + + it("a slug that works again leaves the refused set", () => { + rememberRejectedCodexSlug(A, "gpt-6-sol"); + setCachedCodexSlug(A, "gpt-6-sol"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + }); + + it("a credential reconnected to another account starts afresh", () => { + setCachedCodexSlug(A, "gpt-5.6-terra"); + rememberRejectedCodexSlug(A, "gpt-6-sol"); + setCodexCatalogState(A, { kind: "offered", models: [], at: Date.now() }); + + // Same stored credential, a different ChatGPT account (another plan). + setCachedCodexSlug({ key: A.key, accountId: "acct-other" }, "gpt-6-luna"); + expect(getCachedCodexSlug(A.key)).toBe("gpt-6-luna"); + expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(getCodexCatalogState(A.key)).toBeNull(); + }); + + it("holds a list for its TTL and a failed read for the shorter retry window", () => { + setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }); + expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS)?.kind).toBe( + "offered", ); - // Past TTL: gone. - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull(); + expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); + + setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 }); + expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS)?.kind).toBe( + "unreadable", + ); + expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); }); - it("expired entry is evicted on read", () => { - setCachedCodexSlug("gpt-5.3-codex", 1_000); - getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1); - // Subsequent get should be null too (no resurrection). - expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull(); + it("reports the first unreadable read of a stretch, and re-arms on success", () => { + const unreadable = { + kind: "unreadable" as const, + reason: "timeout" as const, + at: 0, + }; + expect(setCodexCatalogState(A, unreadable)).toBe(true); + expect(setCodexCatalogState(A, unreadable)).toBe(false); + expect(setCodexCatalogState(B, unreadable)).toBe(true); + expect( + setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }), + ).toBe(false); + expect(setCodexCatalogState(A, unreadable)).toBe(true); }); - it("invalidate clears the slot", () => { - setCachedCodexSlug("gpt-5.3-codex"); - invalidateCachedCodexSlug(); - expect(getCachedCodexSlug()).toBeNull(); - }); - - it("set replaces the previous slot", () => { - setCachedCodexSlug("a", 1_000); - setCachedCodexSlug("b", 2_000); - expect(getCachedCodexSlug(2_500)).toBe("b"); + it("holds at most the cap, dropping the least recently used sign-in", () => { + for (let i = 0; i < CODEX_CREDENTIAL_CACHE_MAX; i += 1) { + setCachedCodexSlug( + { key: codexCredentialKey.user(`u${i}`), accountId: `acct-${i}` }, + "gpt-6-sol", + ); + } + // Touch the oldest so the second-oldest is the one to go. + expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + setCachedCodexSlug( + { key: codexCredentialKey.user("new"), accountId: "acct-new" }, + "gpt-6-sol", + ); + expect(codexCredentialCacheSize()).toBe(CODEX_CREDENTIAL_CACHE_MAX); + expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBeNull(); }); }); diff --git a/src/lib/ai/__tests__/codex-slug-chain.test.ts b/src/lib/ai/__tests__/codex-slug-chain.test.ts new file mode 100644 index 000000000..dd21fd811 --- /dev/null +++ b/src/lib/ai/__tests__/codex-slug-chain.test.ts @@ -0,0 +1,315 @@ +/** + * The order a Codex sign-in walks, built from the operator's choice, our + * preference order and the models the account is offered; and the vision + * answer the gates read from it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const annotate = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/logging/context", () => ({ annotate })); + +import { + parseCodexModelList, + type CodexCatalogRead, + type CodexOfferedModel, +} from "../codex-model-catalog"; +import { + DEFAULT_SLUG_FALLBACK_CHAIN, + buildCodexSlugChain, + chainEntryReadsImages, + codexSignInReadsImages, + loadCodexAttemptChain, + resolveCodexVisionSlug, +} from "../codex-slug-chain"; +import { + clearCodexSlugCache, + codexCredentialKey, + rememberRejectedCodexSlug, + setCachedCodexSlug, + setCodexCatalogState, +} from "../codex-slug-cache"; + +const OFFERED = parseCodexModelList( + JSON.parse( + readFileSync( + join(__dirname, "fixtures/codex/models-response.json"), + "utf8", + ), + ), +)!; + +function offered(...slugs: string[]): CodexOfferedModel[] { + return slugs.map((slug, i) => ({ + slug, + listed: true, + priority: i, + inputModalities: ["text", "image"], + specialty: null, + })); +} + +const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const; +let savedEnv: Record = {}; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + clearCodexSlugCache(); + annotate.mockReset(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +describe("buildCodexSlugChain", () => { + it("without a list, walks the static preference order", () => { + for (const list of [null, []]) { + expect(buildCodexSlugChain({ offered: list })).toEqual({ + chain: [...DEFAULT_SLUG_FALLBACK_CHAIN], + source: "static", + }); + } + }); + + it("with the live list: preference order kept to what is offered, then the rest, then the static tail", () => { + const { chain, source } = buildCodexSlugChain({ offered: OFFERED }); + expect(source).toBe("live"); + expect(chain).toEqual([ + // Our preference order, kept to what the account is offered. + "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-6-luna", + "gpt-5.6-luna", + "gpt-5.6-sol", + // Not offered (retired): only reached when everything above failed. + "gpt-5.5", + ]); + // Never offered to the walk: the frontier tier, the hidden review model, + // the hidden specialist line. + for (const skipped of [ + "gpt-6-astra", + "codex-auto-review", + "gpt-daybreak-blue-latest", + ]) { + expect(chain).not.toContain(skipped); + } + }); + + it("follows an account onto a model our order does not know yet, in the list's priority order", () => { + // A later retirement: the gpt-5.6 line gone, two new models offered. + const list: CodexOfferedModel[] = [ + { ...offered("gpt-7-luna")[0], priority: 9 }, + { ...offered("gpt-7-sol")[0], priority: 2 }, + { ...offered("gpt-6-luna")[0], priority: 5 }, + { ...offered("gpt-7-astra")[0], priority: 1 }, + { ...offered("gpt-7-mystery")[0], priority: null }, + ]; + expect(buildCodexSlugChain({ offered: list }).chain).toEqual([ + "gpt-6-luna", + "gpt-7-sol", + "gpt-7-luna", + "gpt-7-mystery", + // the static tail + "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.5", + ]); + }); + + it("does not filter on supported_in_api, which describes API-key access", () => { + const model = parseCodexModelList({ + models: [ + { slug: "gpt-7-sol", visibility: "list", supported_in_api: false }, + ], + })!; + expect(buildCodexSlugChain({ offered: model }).chain[0]).toBe("gpt-7-sol"); + }); + + it("CODEX_MODEL goes first, ahead of the live order", () => { + process.env.CODEX_MODEL = "gpt-6-astra"; + const { chain, source } = buildCodexSlugChain({ offered: OFFERED }); + expect(source).toBe("live"); + expect(chain[0]).toBe("gpt-6-astra"); + expect(chain[1]).toBe("gpt-5.6-terra"); + expect(new Set(chain).size).toBe(chain.length); + }); + + it("CODEX_MODEL_FALLBACK_CHAIN replaces the order outright and the list is ignored", () => { + process.env.CODEX_MODEL = "pinned"; + process.env.CODEX_MODEL_FALLBACK_CHAIN = " a , b,,pinned "; + expect(buildCodexSlugChain({ offered: OFFERED })).toEqual({ + chain: ["pinned", "a", "b"], + source: "operator", + }); + }); + + it("an empty CODEX_MODEL_FALLBACK_CHAIN does not replace anything", () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = " , "; + expect(buildCodexSlugChain({ offered: null }).chain).toEqual([ + ...DEFAULT_SLUG_FALLBACK_CHAIN, + ]); + }); + + it("moves slugs this sign-in was refused to the very end, never out", () => { + const { chain } = buildCodexSlugChain({ + offered: OFFERED, + rejected: new Set(["gpt-5.6-terra", "gpt-6-sol"]), + }); + expect(chain.slice(0, 2)).toEqual(["gpt-6.1-sol", "gpt-6-luna"]); + expect(chain.slice(-2)).toEqual(["gpt-5.6-terra", "gpt-6-sol"]); + }); +}); + +describe("loadCodexAttemptChain", () => { + const credential = { key: codexCredentialKey.user("u1"), accountId: "acct" }; + + it("reads the list for the sign-in and annotates the outcome", async () => { + const readModels = vi.fn(async (): Promise => ({ + models: offered("gpt-6.1-sol"), + from: "live", + failure: null, + })); + const chain = await loadCodexAttemptChain({ + credential, + accessToken: "tok", + readModels, + }); + expect(chain[0]).toBe("gpt-6.1-sol"); + expect(readModels).toHaveBeenCalledWith(credential, { + accessToken: "tok", + accountId: "acct", + }); + expect(annotate).toHaveBeenCalledWith({ + meta: { + "codex.models.chain": "live", + "codex.models.read": "live", + "codex.models.failure": null, + "codex.models.offered": 1, + }, + }); + }); + + it("falls back to the static walk when the read fails, or throws", async () => { + const failing = vi.fn(async (): Promise => ({ + models: null, + from: "live", + failure: "timeout", + })); + expect( + await loadCodexAttemptChain({ + credential, + accessToken: "t", + readModels: failing, + }), + ).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]); + expect(annotate.mock.calls[0][0].meta).toEqual({ + "codex.models.chain": "static", + "codex.models.read": "live", + "codex.models.failure": "timeout", + "codex.models.offered": 0, + }); + + const throwing = vi.fn(async (): Promise => { + throw new Error("bug"); + }); + expect( + await loadCodexAttemptChain({ + credential, + accessToken: "t", + readModels: throwing, + }), + ).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]); + }); + + it("does not read the list when the operator replaced the chain", async () => { + process.env.CODEX_MODEL_FALLBACK_CHAIN = "x,y"; + const readModels = vi.fn(); + expect( + await loadCodexAttemptChain({ credential, accessToken: "t", readModels }), + ).toEqual(["x", "y"]); + expect(readModels).not.toHaveBeenCalled(); + expect(annotate.mock.calls[0][0].meta["codex.models.read"]).toBe("skipped"); + }); +}); + +describe("vision for a Codex sign-in", () => { + const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" }; + const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" }; + + it("reads the sign-in's own working slug, else the head of its own order", () => { + setCodexCatalogState(B, { + kind: "offered", + models: offered("gpt-6-luna"), + at: Date.now(), + }); + setCachedCodexSlug(A, "gpt-6.1-sol"); + expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6.1-sol"); + expect(resolveCodexVisionSlug(B.key)).toBe("gpt-6-luna"); + expect(resolveCodexVisionSlug(codexCredentialKey.central)).toBe( + "gpt-5.6-terra", + ); + }); + + it("takes the list's input modalities over the model family", () => { + // A family the gates know as vision-capable, listed as text-only. + setCodexCatalogState(A, { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }); + expect(codexSignInReadsImages(A.key)).toBe(false); + + // A text-only family by name, listed with image input. + setCodexCatalogState(B, { + kind: "offered", + models: offered("o3-mini"), + at: Date.now(), + }); + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + + it("falls back to the model family when the list says nothing", () => { + setCodexCatalogState(A, { + kind: "offered", + // A text-only family the gates know, listed without modalities. + models: [{ ...offered("o3-mini")[0], inputModalities: null }], + at: Date.now(), + }); + expect(codexSignInReadsImages(A.key)).toBe(false); + // No list at all: the static head, a known vision family. + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + + it("skips a refused slug for the vision answer too", () => { + rememberRejectedCodexSlug(A, "gpt-5.6-terra"); + expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6-sol"); + }); + + it("routes each chain entry to its own sign-in", () => { + setCodexCatalogState( + { key: codexCredentialKey.user("u1"), accountId: "acct-1" }, + { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }, + ); + expect(chainEntryReadsImages("codex", "u1", null)).toBe(false); + expect(chainEntryReadsImages("codex", "u2", null)).toBe(true); + expect(chainEntryReadsImages("admin-codex", "u1", null)).toBe(true); + expect(chainEntryReadsImages("openai", "u1", "gpt-4")).toBe(false); + expect(chainEntryReadsImages("anthropic", "u1", "claude-sonnet-4-5")).toBe( + true, + ); + }); +}); diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index 6ac75d8a3..badd9f330 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -13,6 +13,18 @@ import { setCachedCodexSlug, CODEX_SLUG_CACHE_TTL_MS, } from "../codex-slug-cache"; +import { + DEFAULT_SLUG_FALLBACK_CHAIN, + buildCodexSlugChain, +} from "../codex-slug-chain"; + +/** The sign-in every client in this file runs on. */ +const KEY = "user:test"; +const CREDENTIAL = { key: KEY, accountId: "acct" }; + +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; /** * Slug-drift defence. @@ -110,6 +122,8 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test", accountId: "acct", onTokenRefresh, @@ -123,7 +137,7 @@ describe("Codex slug fallback chain", () => { "gpt-6-sol", ]); expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol"); - expect(getCachedCodexSlug()).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-6-sol"); } finally { if (origModel === undefined) delete process.env.CODEX_MODEL; else process.env.CODEX_MODEL = origModel; @@ -138,6 +152,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -154,7 +169,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.cacheState).toBe("miss"); expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => { @@ -165,6 +180,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -183,7 +199,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); // The working slug — not the rejected one — is now cached. - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 400 with model_not_found body", async () => { @@ -199,6 +215,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -218,6 +235,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -235,6 +253,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -255,6 +274,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -276,6 +296,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "expired", accountId: "acct", onTokenRefresh: vi @@ -304,6 +325,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -332,11 +354,12 @@ describe("Codex slug fallback chain", () => { }); it("cache hit — second call goes straight to the cached slug", async () => { - setCachedCodexSlug("gpt-4o"); + setCachedCodexSlug(CREDENTIAL, "gpt-4o"); const mockFetch = vi.fn().mockResolvedValue(sseSuccessResponse()); vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -350,8 +373,8 @@ describe("Codex slug fallback chain", () => { }); it("cache invalidated when cached slug starts rejecting", async () => { - setCachedCodexSlug("gpt-5-codex"); - expect(getCachedCodexSlug()).toBe("gpt-5-codex"); + setCachedCodexSlug(CREDENTIAL, "gpt-5-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5-codex"); const mockFetch = vi .fn() @@ -360,6 +383,7 @@ describe("Codex slug fallback chain", () => { vi.stubGlobal("fetch", mockFetch); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "test", accountId: "acct", onTokenRefresh, @@ -369,7 +393,7 @@ describe("Codex slug fallback chain", () => { // After walking to "gpt-5.3-codex" (the next entry after the // cached one), the cache is updated to the new working slug. - expect(getCachedCodexSlug()).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); }); it("isSlugRejection helper recognises canonical rejection bodies", () => { @@ -401,7 +425,7 @@ describe("Codex slug fallback chain", () => { // served the gpt-5.6 and gpt-6 lines. Terra, verified live, leads; its // upstream successor and the rest of the gpt-6 line follow before terra's // own siblings, which a line rotation would retire together with it. - expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ + expect([...DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ "gpt-5.6-terra", "gpt-6-sol", "gpt-6.1-sol", @@ -419,7 +443,7 @@ describe("Codex slug fallback chain", () => { "gpt-5.3-codex", "gpt-5.2", ]) { - expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired); + expect(DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired); } }); @@ -430,8 +454,8 @@ describe("Codex slug fallback chain", () => { // "off" (the Codex client sends `effort: "none"` for it) that reads // images. Nothing is filtered out first: a new line joins the default // only together with its family support. - expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0); - for (const slug of __test.DEFAULT_SLUG_FALLBACK_CHAIN) { + expect(DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0); + for (const slug of DEFAULT_SLUG_FALLBACK_CHAIN) { const family = openAIModelFamily(slug); expect(family, slug).not.toBeNull(); expect(family?.reasoning, slug).not.toBeNull(); @@ -445,13 +469,13 @@ describe("Codex slug fallback chain", () => { } }); - it("loadFallbackChain falls back to the default ladder with no env override", () => { + it("without a model list or an env override, the walk is the default ladder", () => { const origModel = process.env.CODEX_MODEL; const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN; delete process.env.CODEX_MODEL; delete process.env.CODEX_MODEL_FALLBACK_CHAIN; try { - expect(__test.loadFallbackChain()).toEqual([ + expect(buildCodexSlugChain({ offered: null }).chain).toEqual([ "gpt-5.6-terra", "gpt-6-sol", "gpt-6.1-sol", @@ -469,11 +493,11 @@ describe("Codex slug fallback chain", () => { } }); - it("loadFallbackChain folds CODEX_MODEL into position 0", () => { + it("the static walk folds CODEX_MODEL into position 0", () => { const orig = process.env.CODEX_MODEL; process.env.CODEX_MODEL = "custom-pinned-slug"; try { - const chain = __test.loadFallbackChain(); + const { chain } = buildCodexSlugChain({ offered: null }); expect(chain[0]).toBe("custom-pinned-slug"); // Defaults still present after position 0. expect(chain).toContain("gpt-5.5"); diff --git a/src/lib/ai/__tests__/codex-timeout.test.ts b/src/lib/ai/__tests__/codex-timeout.test.ts index b0c68750b..79033a3c8 100644 --- a/src/lib/ai/__tests__/codex-timeout.test.ts +++ b/src/lib/ai/__tests__/codex-timeout.test.ts @@ -45,6 +45,7 @@ function sseResponse(): Response { function makeClient() { return new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "acct", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/fixtures/codex/models-response.json b/src/lib/ai/__tests__/fixtures/codex/models-response.json new file mode 100644 index 000000000..7c2488b94 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/codex/models-response.json @@ -0,0 +1,1012 @@ +{ + "models": [ + { + "slug": "gpt-6-astra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "supports_reasoning_effort_updates": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Astra", + "description": "Frontier intelligence for the most demanding work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 2, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6.1-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6.1-Sol", + "description": "Latest workhorse model for coding and everyday work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": { + "message": "Maximize usage with GPT-6.1 Sol. Try it on complex work for near-Astra performance at a lower cost." + }, + "upgrade": null, + "priority": 1, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "law", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true, + "supports_reasoning_effort_updates": true + }, + { + "slug": "gpt-6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Sol", + "description": "Previous generation workhorse model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 3, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Luna", + "description": "Fast and affordable model for easier tasks.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 4, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": ["send_user_message_async", "clock"], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Sol", + "description": "Older generation workhorse model.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 5, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-terra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Terra", + "description": "Older balanced model for straightforward work.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 8, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Luna", + "description": "Older fast and efficient model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-luna", + "migration_markdown": "Meet GPT-6 Luna\n\nOur latest Luna is significantly more efficient, so your usage limits go even further. Reach for it for any job that doesn't require frontier intelligence.\n", + "retirement_at": null + }, + "priority": 9, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-daybreak-blue-latest", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "auto_review_model_override": null, + "model_specialty": "cyber", + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Daybreak Blue", + "description": "Latest frontier agentic coding model for broad defensive cybersecurity work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.142.2", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 11, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "finserv", + "free", + "free_workspace", + "go", + "hc", + "k12", + "plus", + "pro", + "prolite", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "default_service_tier": null, + "service_tiers": [], + "additional_speed_tiers": [], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "codex-auto-review", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": ["text", "image"], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.98.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 43, + "model_messages": { + "instructions_template": "(instructions trimmed for the fixture)" + }, + "experimental_supported_tools": [], + "available_in_plans": [ + "business", + "edu", + "edu_plus", + "edu_pro", + "education", + "ent26", + "enterprise", + "enterprise_cbp_automation", + "enterprise_cbp_trial", + "enterprise_cbp_usage_based", + "finserv", + "go", + "hc", + "plus", + "pro", + "prolite", + "promax", + "quorum", + "sci", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "team" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": ["fast"], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + } + ] +} diff --git a/src/lib/ai/__tests__/provider-contract.test.ts b/src/lib/ai/__tests__/provider-contract.test.ts index 70d993be6..b7954bb9e 100644 --- a/src/lib/ai/__tests__/provider-contract.test.ts +++ b/src/lib/ai/__tests__/provider-contract.test.ts @@ -10,6 +10,10 @@ import { type CompletionResult, } from "../types"; +/** A model list that cannot be read: the client walks its static order. */ +const NO_MODEL_LIST = async () => + ({ models: null, from: "live", failure: "network" }) as const; + // safeFetch's requirePublicHost path runs through undici's own `fetch` // (version-locked with its dispatcher). Delegate it to the global `fetch` // stub these tests install so the existing interception still applies. @@ -215,6 +219,8 @@ describe("AIProvider contract", () => { ); const provider: AIProvider = new CodexClient({ + credentialKey: "user:test", + readModels: NO_MODEL_LIST, accessToken: "test-token", accountId: "acct-test", onTokenRefresh: vi diff --git a/src/lib/ai/__tests__/response-timeout-binding.test.ts b/src/lib/ai/__tests__/response-timeout-binding.test.ts index 37691b266..d0d3ee69f 100644 --- a/src/lib/ai/__tests__/response-timeout-binding.test.ts +++ b/src/lib/ai/__tests__/response-timeout-binding.test.ts @@ -81,6 +81,7 @@ const CLIENTS: Record AIProvider> = { new AnthropicClient({ apiKey: "k", model: "claude-sonnet-4-6" }), codex: () => new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts index a97c0f021..90c3e9cf4 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts @@ -308,6 +308,7 @@ describe("the forced final round on each provider wire", () => { }); vi.stubGlobal("fetch", upstream); const client = new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2bcef2a08..7655f5b03 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -21,15 +21,11 @@ import { } from "./codex-reasoning-wire"; import { getCachedCodexSlug, - invalidateCachedCodexSlug, + rememberRejectedCodexSlug, setCachedCodexSlug, + type CodexCredential, } from "./codex-slug-cache"; -import { - DEFAULT_SLUG_FALLBACK_CHAIN, - loadFallbackChain, -} from "./codex-slug-chain"; - -export { resolveCodexVisionSlug } from "./codex-slug-chain"; +import { loadCodexAttemptChain } from "./codex-slug-chain"; /** * Codex backend client — talks to `chatgpt.com/backend-api/codex/responses` @@ -47,11 +43,10 @@ export { resolveCodexVisionSlug } from "./codex-slug-chain"; * * Response is always SSE (server rejects `stream: false` with 400). * - * v1.4.15 (Phase C1) adds a slug-drift defence — see - * `docs/codex-protocol-spec.md` §7b. The client walks an ordered - * fallback chain on every fresh request series (short-circuiting on - * a cached working slug) so a single upstream allow-list rotation no - * longer bricks the integration. + * Slug-drift defence (`docs/codex-protocol-spec.md` §7b): the client tries + * its sign-in's working slug first, then walks an order built from the + * models the account is offered (`codex-slug-chain.ts`), so an upstream + * retirement does not take the integration down. */ /** @@ -231,10 +226,13 @@ interface CodexClientConfig { accountId: string; onTokenRefresh: () => Promise<{ accessToken: string; accountId: string }>; /** - * Override the slug fallback chain (test-only). When unset, the - * chain is loaded from env + DEFAULT_SLUG_FALLBACK_CHAIN. + * The stored credential (`userCodexCredentialKey` or + * `CENTRAL_CODEX_CREDENTIAL_KEY`) its working slug, refused slugs and model + * list are kept under; and two test seams (see `loadCodexAttemptChain`). */ + credentialKey: string; slugChain?: string[]; + readModels?: Parameters[0]["readModels"]; } /** Diagnostic shape returned to the route layer for Wide-Event logging. */ @@ -269,53 +267,59 @@ export class CodexClient implements AIProvider { accessToken: string; accountId: string; }>; - private readonly slugChain: string[]; + private readonly selection: Pick< + CodexClientConfig, + "credentialKey" | "slugChain" | "readModels" + >; private lastDiagnostics: CodexAttemptDiagnostics | null = null; constructor(config: CodexClientConfig) { this.accessToken = config.accessToken; this.accountId = config.accountId; this.onTokenRefresh = config.onTokenRefresh; - this.slugChain = config.slugChain ?? loadFallbackChain(); - if (this.slugChain.length === 0) { - // Hardcoded floor — even with a misconfigured env var we always - // try at least one slug. - this.slugChain = [...DEFAULT_SLUG_FALLBACK_CHAIN]; - } + this.selection = config; + } + + private get credential(): CodexCredential { + return { key: this.selection.credentialKey, accountId: this.accountId }; } /** - * Diagnostics for the most recent generateCompletion() call, used by - * the route layer for Wide-Event annotations. Captures the order of - * attempted slugs, whether the cache was hit, and which slug - * eventually worked (or null on all-failed). + * The last generateCompletion(): slugs attempted in order, whether the + * working slug was cached, and which slug answered (null when none did). */ getLastDiagnostics(): CodexAttemptDiagnostics | null { return this.lastDiagnostics; } - /** - * Build the slug-attempt order: cached slug first (if any and not - * expired), then the rest of the fallback chain in order, deduped - * against the cached slug. - */ - private buildAttemptOrder(): { order: string[]; cacheState: "hit" | "miss" } { - const cached = getCachedCodexSlug(); - if (cached === null) { - return { order: [...this.slugChain], cacheState: "miss" }; - } - const order = [cached, ...this.slugChain.filter((s) => s !== cached)]; - return { order, cacheState: "hit" }; - } - async generateCompletion( params: CompletionParams, ): Promise { - const { order, cacheState } = this.buildAttemptOrder(); + // The working slug alone first: the account's list is read only when + // there is none, or when it stops working. + const cached = getCachedCodexSlug(this.selection.credentialKey); + const cacheState = cached === null ? "miss" : "hit"; + const loadChain = () => + loadCodexAttemptChain({ + ...this.selection, + credential: this.credential, + accessToken: this.accessToken, + }); + const queue = cached ? [cached] : await loadChain(); + let chainLoaded = cached === null; const attempted: string[] = []; let lastSlugRejectionError: Error | null = null; - for (const slug of order) { + for (;;) { + if (queue.length === 0) { + if (chainLoaded) break; + chainLoaded = true; + queue.push( + ...(await loadChain()).filter((s) => !attempted.includes(s)), + ); + continue; + } + const slug = queue.shift() as string; attempted.push(slug); let wire = planCodexReasoning(params.reasoning, slug); @@ -334,7 +338,7 @@ export class CodexClient implements AIProvider { cacheState, workingSlug: slug, }; - setCachedCodexSlug(slug); + setCachedCodexSlug(this.credential, slug); return this.parseResponse(retryAfterRefresh, slug, params, wire); } // Even after refresh — surface the auth failure verbatim, do @@ -374,7 +378,7 @@ export class CodexClient implements AIProvider { cacheState, workingSlug: slug, }; - setCachedCodexSlug(slug); + setCachedCodexSlug(this.credential, slug); return this.parseResponse(attempt, slug, params, wire); } @@ -383,10 +387,9 @@ export class CodexClient implements AIProvider { const bodyExcerpt = redactBody(rawBody); if (isSlugRejection(attempt.status, bodyExcerpt)) { - // Slug rejected — drop the cache (in case it pointed here), - // record the failure for diagnostics, and walk to the next - // chain slot. - invalidateCachedCodexSlug(); + // Refused for this sign-in: out of the working slot and to the back + // of its line, even while its list still offers it; walk on. + rememberRejectedCodexSlug(this.credential, slug); const slugErr = new Error( `Codex slug "${slug}" rejected (${attempt.status})`, ); @@ -898,10 +901,7 @@ function redactBody(rawBody: string): string { .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***"); } -// Test-only re-exports — keeps the spec-driven defaults visible to unit -// tests without exporting a settable mutable. +// Test-only: the rejection matcher. export const __test = { - DEFAULT_SLUG_FALLBACK_CHAIN, isSlugRejection, - loadFallbackChain, }; diff --git a/src/lib/ai/codex-model-catalog.ts b/src/lib/ai/codex-model-catalog.ts new file mode 100644 index 000000000..0ea5781b6 --- /dev/null +++ b/src/lib/ai/codex-model-catalog.ts @@ -0,0 +1,297 @@ +/** + * The model list a Codex sign-in is offered, read the way the official Codex + * CLI reads it. + * + * A fixed preference list goes stale at every retirement: on 2026-10-14 every + * model the shipped list named was retired for ChatGPT sign-in and Codex + * answered 503 until a release changed the list. The backend says which + * models an account may use, on the same sign-in as the responses call: + * + * GET https://chatgpt.com/backend-api/codex/models?client_version= + * Authorization: Bearer + * ChatGPT-Account-ID: + * + * Wire shape, from openai/codex `codex-rs/protocol/src/openai_models.rs` + * (`ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`), read + * by `codex-rs/codex-api/src/endpoint/models.rs` (`ModelsClient`, which + * appends `client_version`) under the 5 s bound and per-account cache + * identity of `codex-rs/model-provider/src/models_endpoint.rs` and + * `models_identity.rs`: + * + * { "models": [ { "slug": "gpt-6-luna", "visibility": "list", + * "supported_in_api": true, "priority": 4, + * "input_modalities": ["text", "image"], + * "model_specialty": null, ... } ] } + * + * Only the fields read below matter here; everything else in an entry (the + * instruction templates make up most of a ~500 KB body) is ignored. + * + * The list is a hint, never a gate. A read that fails, times out, runs past + * the size bound, does not parse or comes back empty leaves the client on + * its built-in order, exactly as before the list existed. A failed read is + * retried after `CODEX_CATALOG_RETRY_MS`, not on every request, and one + * signal per sign-in reports the stretch. + */ +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; +import { readBodyCapped } from "@/lib/http/read-capped"; +import { emitSignal } from "@/lib/logging/signal"; +import { + codexCredentialKey, + getCodexCatalogState, + setCodexCatalogState, + type CodexCredential, +} from "./codex-slug-cache"; + +const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models"; + +/** + * The `client_version` sent with the read. The backend leaves out models + * whose `minimal_client_version` is above it (every gpt-6 entry in the + * upstream `models-manager/models.json` names 0.153.0 or later), so a version + * far behind the current Codex CLI hides the models an account actually has. + * This is the Codex CLI release current when it was last checked + * (rust-v0.165.0, 2026-10-27); move it when a new line needs a newer client. + */ +export const CODEX_MODELS_CLIENT_VERSION = "0.165.0"; + +/** Same bound as the Codex CLI's model refresh. */ +const MODELS_TIMEOUT_MS = 5_000; +/** The upstream bundle is ~540 KB for twelve models. */ +const MODELS_MAX_BYTES = 4 * 1024 * 1024; +/** More entries than any account has been offered; the rest are ignored. */ +const MAX_MODELS = 200; +/** What a slug may look like before it goes into a request body or a log. */ +const SLUG_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/; + +const ORIGINATOR = "healthlog"; +const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)"; + +/** One model the account is offered, reduced to what the client uses. */ +export interface CodexOfferedModel { + slug: string; + /** `visibility: "list"`: shown in the Codex CLI's picker. */ + listed: boolean; + /** The list's own order (lower first); null when the entry has none. */ + priority: number | null; + /** `input_modalities`, or null when the entry does not say. */ + inputModalities: readonly string[] | null; + /** `model_specialty` (e.g. a security-focused line), null for general models. */ + specialty: string | null; +} + +/** Why a read of the list gave nothing usable. A closed set: it is log meta. */ +export type CodexCatalogFailure = + | "timeout" + | "network" + | "auth" + | "http_error" + | "too_large" + | "unparsable" + | "empty"; + +/** What one request learned about the list. */ +export interface CodexCatalogRead { + /** The offered models, or null when the list could not be used. */ + models: readonly CodexOfferedModel[] | null; + /** Whether this request read the backend or used an earlier read. */ + from: "live" | "cache"; + failure: CodexCatalogFailure | null; +} + +/** The access a read needs: the same sign-in as the responses call. */ +export interface CodexCatalogAuth { + accessToken: string; + accountId: string; +} + +/** + * Parse a `/models` body. Returns null when the body is not the documented + * envelope; entries without a usable slug are skipped, not fatal. An empty + * array is returned as such and the caller treats it as unusable. + */ +export function parseCodexModelList(body: unknown): CodexOfferedModel[] | null { + if (!body || typeof body !== "object") return null; + const list = (body as { models?: unknown }).models; + if (!Array.isArray(list)) return null; + const seen = new Set(); + const out: CodexOfferedModel[] = []; + for (const raw of list.slice(0, MAX_MODELS)) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const slug = typeof entry.slug === "string" ? entry.slug.trim() : ""; + if (!SLUG_SHAPE.test(slug) || seen.has(slug)) continue; + seen.add(slug); + const modalities = Array.isArray(entry.input_modalities) + ? entry.input_modalities.filter((m): m is string => typeof m === "string") + : null; + out.push({ + slug, + listed: entry.visibility === "list", + priority: + typeof entry.priority === "number" && Number.isFinite(entry.priority) + ? entry.priority + : null, + inputModalities: modalities, + specialty: + typeof entry.model_specialty === "string" && entry.model_specialty + ? entry.model_specialty + : null, + }); + } + return out; +} + +/** One read of the backend, never cached and never thrown out of. */ +export async function fetchCodexModelList( + auth: CodexCatalogAuth, +): Promise< + | { ok: true; models: CodexOfferedModel[] } + | { ok: false; reason: CodexCatalogFailure; status: number | null } +> { + let res: Response; + try { + res = await safeFetch( + `${CODEX_MODELS_ENDPOINT}?client_version=${CODEX_MODELS_CLIENT_VERSION}`, + { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-ID": auth.accountId, + originator: ORIGINATOR, + "User-Agent": USER_AGENT, + }, + }, + { timeoutMs: MODELS_TIMEOUT_MS }, + ); + } catch (error) { + const timedOut = + (error instanceof SafeFetchError && error.kind === "timeout") || + (error instanceof Error && error.name === "TimeoutError"); + return { + ok: false, + reason: timedOut ? "timeout" : "network", + status: null, + }; + } + if (!res.ok) { + void res.body?.cancel().catch(() => {}); + return { + ok: false, + reason: res.status === 401 || res.status === 403 ? "auth" : "http_error", + status: res.status, + }; + } + let text: string; + try { + const read = await readBodyCapped(res, MODELS_MAX_BYTES); + if (!read.ok) return { ok: false, reason: "too_large", status: res.status }; + text = read.text; + } catch (error) { + // The timeout signal also covers the body; a stall mid-body lands here. + const timedOut = error instanceof Error && error.name === "TimeoutError"; + return { + ok: false, + reason: timedOut ? "timeout" : "network", + status: res.status, + }; + } + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return { ok: false, reason: "unparsable", status: res.status }; + } + const models = parseCodexModelList(json); + if (models === null) { + return { ok: false, reason: "unparsable", status: res.status }; + } + if (models.length === 0) { + return { ok: false, reason: "empty", status: res.status }; + } + return { ok: true, models }; +} + +/** Reads in flight, one per sign-in, so a burst of requests reads once. */ +const inflight = new Map>(); + +/** + * The account's model list for this sign-in: a trusted earlier read when + * there is one, else one bounded read of the backend shared by every request + * that asks meanwhile. + * + * An expired token (401) is not remembered: the responses call refreshes it + * on its own 401, and the next request reads the list with the new one. + * Every other failure is remembered for `CODEX_CATALOG_RETRY_MS`. + */ +export function readCodexModelCatalog( + credential: CodexCredential, + auth: CodexCatalogAuth, +): Promise { + const state = getCodexCatalogState(credential.key); + if (state?.kind === "offered") { + return Promise.resolve({ + models: state.models, + from: "cache", + failure: null, + }); + } + if (state?.kind === "unreadable") { + return Promise.resolve({ + models: null, + from: "cache", + failure: state.reason, + }); + } + const pending = inflight.get(credential.key); + if (pending) return pending; + const read = fetchCodexModelList(auth) + .then((result): CodexCatalogRead => { + if (result.ok) { + setCodexCatalogState(credential, { + kind: "offered", + models: result.models, + at: Date.now(), + }); + return { models: result.models, from: "live", failure: null }; + } + if (result.status !== 401) { + const first = setCodexCatalogState(credential, { + kind: "unreadable", + reason: result.reason, + at: Date.now(), + }); + if (first) { + emitSignal({ + action: "ai.codex.models_unreadable", + level: "warn", + meta: { + connection: + credential.key === codexCredentialKey.central + ? "central" + : "personal", + reason: result.reason, + status: result.status, + }, + }); + } + } + return { models: null, from: "live", failure: result.reason }; + }) + .finally(() => { + if (inflight.get(credential.key) === read) { + inflight.delete(credential.key); + } + }); + inflight.set(credential.key, read); + return read; +} + +/** The offered models from an earlier read, without touching the network. */ +export function cachedCodexModels( + key: string, + now: number = Date.now(), +): readonly CodexOfferedModel[] | null { + const state = getCodexCatalogState(key, now); + return state?.kind === "offered" ? state.models : null; +} diff --git a/src/lib/ai/codex-slug-cache.ts b/src/lib/ai/codex-slug-cache.ts index 86b0b3a30..b06d12c77 100644 --- a/src/lib/ai/codex-slug-cache.ts +++ b/src/lib/ai/codex-slug-cache.ts @@ -1,62 +1,210 @@ /** - * Process-local positive cache for working Codex model slugs. + * Process-local memory per Codex sign-in: the model that last worked, the + * models the backend refused, and the model list the account was offered. * - * (v1.4.15) — slug-drift defence per - * `docs/codex-protocol-spec.md` §7b. The ChatGPT-Codex backend rotates - * its allow-list silently; pinning a single `CODEX_MODEL` slug bricks - * the integration the moment upstream flips it. Instead of pinning, - * `CodexClient` walks an ordered fallback chain on every fresh - * request series and caches the first slug that returns 200 + - * `response.completed` for `CACHE_TTL_MS` so subsequent requests skip - * the chain walk. + * The ChatGPT-Codex backend offers different models to different plans and + * retires them without notice (docs/codex-protocol-spec.md §7b). One slot for + * the whole process meant two sign-ins on different plans overwrote each + * other's working model, and the vision gate read whichever had answered + * last. Every entry here belongs to one stored credential instead: * - * Lives in its own module so: - * - Tests can `clearCodexSlugCache()` between cases. - * - The shared Map state stays out of `CodexClient` itself (so - * instance creation in `resolveProvider` doesn't reset the cache). - * - Future telemetry (cache hit-rate emission) hooks here cleanly. + * - `user:` for a person's own Codex connection; + * - `central` for the operator-shared connection (`admin-codex`). + * + * An entry also remembers a digest of the ChatGPT account id it was learned + * under. A credential reconnected to a different account (a different plan) + * starts from an empty entry the first time a client runs on it, so nothing + * learned on the old account carries over. No token, and no raw account id, + * is ever a key or a value here. + * + * Bounded both ways: every part of an entry expires on its own TTL, and the + * map holds at most `CODEX_CREDENTIAL_CACHE_MAX` sign-ins, the least recently + * used dropped first. A dropped entry costs one extra model-list read and at + * most one refused request; it never costs an answer. */ +import { createHash } from "node:crypto"; +import type { + CodexCatalogFailure, + CodexOfferedModel, +} from "./codex-model-catalog"; -const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per spec §7b +/** How long a working model stays first in line (spec §7b). */ +export const CODEX_SLUG_CACHE_TTL_MS = 60 * 60 * 1000; +/** How long a model the backend refused stays at the back of the line. */ +export const CODEX_REJECTED_SLUG_TTL_MS = 6 * 60 * 60 * 1000; +/** How long the account's model list is trusted before it is read again. */ +export const CODEX_CATALOG_TTL_MS = 60 * 60 * 1000; +/** How long a list that could not be read waits before the next attempt. */ +export const CODEX_CATALOG_RETRY_MS = 10 * 60 * 1000; +/** Most sign-ins held at once. */ +export const CODEX_CREDENTIAL_CACHE_MAX = 1_000; -interface CachedEntry { - slug: string; - cachedAtMs: number; +/** The keys a sign-in's memory is kept under. */ +export const codexCredentialKey = { + /** A person's own Codex connection (the `codex` chain entry). */ + user: (userId: string): string => `user:${userId}`, + /** The operator-shared central connection (the `admin-codex` entry). */ + central: "central", +} as const; + +/** The sign-in a client runs on: its stored credential and its account. */ +export interface CodexCredential { + key: string; + accountId: string; +} + +/** What the last read of the account's model list left behind. */ +export type CodexCatalogState = + | { kind: "offered"; models: readonly CodexOfferedModel[]; at: number } + | { kind: "unreadable"; reason: CodexCatalogFailure; at: number }; + +interface CredentialEntry { + accountDigest: string; + working: { slug: string; at: number } | null; + rejected: Map; + catalog: CodexCatalogState | null; + /** Whether the current unreadable stretch has been reported. */ + unreadableReported: boolean; +} + +const entries = new Map(); + +function accountDigest(accountId: string): string { + return createHash("sha256") + .update(`codex-account:${accountId}`) + .digest("hex") + .slice(0, 32); +} + +/** Read an entry without binding it to an account; LRU-touches a hit. */ +function peek(key: string): CredentialEntry | undefined { + const entry = entries.get(key); + if (!entry) return undefined; + entries.delete(key); + entries.set(key, entry); + return entry; } /** - * Cache key — currently a single global slot ("codex"). Kept in a Map - * so future per-account / per-model-family scoping is a one-line - * change. + * The entry for a running client's sign-in, created on first use and started + * afresh when the credential now belongs to a different account. */ -const CACHE_KEY = "codex"; +function bind(credential: CodexCredential): CredentialEntry { + const digest = accountDigest(credential.accountId); + const existing = peek(credential.key); + if (existing && existing.accountDigest === digest) return existing; + const fresh: CredentialEntry = { + accountDigest: digest, + working: null, + rejected: new Map(), + catalog: null, + unreadableReported: false, + }; + entries.set(credential.key, fresh); + while (entries.size > CODEX_CREDENTIAL_CACHE_MAX) { + const oldest = entries.keys().next().value; + if (oldest === undefined) break; + entries.delete(oldest); + } + return fresh; +} -const cache = new Map(); - -export function getCachedCodexSlug(now: number = Date.now()): string | null { - const entry = cache.get(CACHE_KEY); - if (!entry) return null; - if (now - entry.cachedAtMs > CACHE_TTL_MS) { - cache.delete(CACHE_KEY); +/** The model that last worked for this sign-in, while it is fresh. */ +export function getCachedCodexSlug( + key: string, + now: number = Date.now(), +): string | null { + const entry = peek(key); + if (!entry?.working) return null; + if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) { + entry.working = null; return null; } - return entry.slug; + return entry.working.slug; } export function setCachedCodexSlug( + credential: CodexCredential, slug: string, now: number = Date.now(), ): void { - cache.set(CACHE_KEY, { slug, cachedAtMs: now }); + const entry = bind(credential); + entry.working = { slug, at: now }; + entry.rejected.delete(slug); } -export function invalidateCachedCodexSlug(): void { - cache.delete(CACHE_KEY); +/** + * Record that the backend refused `slug` for this sign-in (a retired or + * not-offered model). It drops out of the working slot and goes to the back + * of the line until `CODEX_REJECTED_SLUG_TTL_MS` passes, even while the + * account's list still offers it. + */ +export function rememberRejectedCodexSlug( + credential: CodexCredential, + slug: string, + now: number = Date.now(), +): void { + const entry = bind(credential); + if (entry.working?.slug === slug) entry.working = null; + entry.rejected.set(slug, now); } -/** Test-only: reset the cache between cases. */ +/** The models this sign-in was refused recently. */ +export function rejectedCodexSlugs( + key: string, + now: number = Date.now(), +): ReadonlySet { + const entry = peek(key); + if (!entry) return new Set(); + for (const [slug, at] of entry.rejected) { + if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug); + } + return new Set(entry.rejected.keys()); +} + +/** The last read of the account's model list, while it is still trusted. */ +export function getCodexCatalogState( + key: string, + now: number = Date.now(), +): CodexCatalogState | null { + const entry = peek(key); + const state = entry?.catalog; + if (!entry || !state) return null; + const ttl = + state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS; + if (now - state.at > ttl) { + entry.catalog = null; + return null; + } + return state; +} + +/** + * Store a read of the account's model list. Returns true when this is the + * first unreadable result since the list last answered, which is the one + * the caller reports; a later failure in the same stretch returns false. + */ +export function setCodexCatalogState( + credential: CodexCredential, + state: CodexCatalogState, +): boolean { + const entry = bind(credential); + entry.catalog = state; + if (state.kind === "offered") { + entry.unreadableReported = false; + return false; + } + if (entry.unreadableReported) return false; + entry.unreadableReported = true; + return true; +} + +/** Test-only: forget every sign-in. */ export function clearCodexSlugCache(): void { - cache.clear(); + entries.clear(); } -export const CODEX_SLUG_CACHE_TTL_MS = CACHE_TTL_MS; +/** Test-only: how many sign-ins are held. */ +export function codexCredentialCacheSize(): number { + return entries.size; +} diff --git a/src/lib/ai/codex-slug-chain.ts b/src/lib/ai/codex-slug-chain.ts index 550f3a24d..8b345e62d 100644 --- a/src/lib/ai/codex-slug-chain.ts +++ b/src/lib/ai/codex-slug-chain.ts @@ -1,17 +1,34 @@ /** * The Codex slug ladder: which ChatGPT-auth model slugs the client tries, in * order, and the slug the vision gate reasons about before a request runs. - * The walk itself (which statuses move to the next rung, the positive cache) - * lives in `codex-client.ts` and `codex-slug-cache.ts`; docs/codex-protocol-spec.md - * §7b describes the whole defence. + * The walk itself (which statuses move to the next rung) lives in + * `codex-client.ts`; the per-sign-in memory in `codex-slug-cache.ts`; the + * account's own model list in `codex-model-catalog.ts`. + * docs/codex-protocol-spec.md §7b describes the whole defence. */ -import { getCachedCodexSlug } from "./codex-slug-cache"; +import { annotate } from "@/lib/logging/context"; +import { + cachedCodexModels, + readCodexModelCatalog, + type CodexCatalogRead, + type CodexOfferedModel, +} from "./codex-model-catalog"; +import { + codexCredentialKey, + getCachedCodexSlug, + rejectedCodexSlugs, + type CodexCredential, +} from "./codex-slug-cache"; +import { + supportsVisionForConfig, + type VisionProviderType, +} from "./vision-capability"; /** - * Default fallback chain. Override via `CODEX_MODEL_FALLBACK_CHAIN` - * (comma-separated); `CODEX_MODEL` is folded into position 0 when set, - * duplicates dropped. The backend rotates its ChatGPT-auth slugs without - * notice: by 2026-10-24 it rejected everything from `gpt-5.5` down. + * Our preference order. With the account's model list in hand it is filtered + * to what the account is offered; without it, it is the walk. Override via + * `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated); `CODEX_MODEL` is folded + * into position 0 when set, duplicates dropped. * * `gpt-5.6-terra` stays first because it is the slug verified against the * backend. The upstream catalog (`codex-rs/models-manager/models.json`) @@ -33,37 +50,218 @@ export const DEFAULT_SLUG_FALLBACK_CHAIN = [ ] as const; /** - * The slug the codex vision gate reasons about *before* a request runs: the - * cached working slug if one is live, else the first chain entry. Lets the - * Lab-OCR capability resolver decide whether codex can read images without - * standing up a client or making a network call. Best-effort — the actual - * routed slug may differ once the chain walk runs, which the runtime probe + - * graceful fallback (text/local OCR) covers. + * The frontier tier (`gpt-6-astra` today, and any later `-astra`), skipped + * even when offered, for the reason on `DEFAULT_SLUG_FALLBACK_CHAIN`. An + * operator who wants it names it in `CODEX_MODEL`. */ -export function resolveCodexVisionSlug(): string | null { - const cached = getCachedCodexSlug(); - if (cached) return cached; - return loadFallbackChain()[0] ?? null; +const SKIPPED_TIER = /(?:^|-)astra(?:$|-)/; + +/** Where the order a client walks came from. A closed set: it is log meta. */ +export type CodexChainSource = "operator" | "live" | "static"; + +function dedupe(slugs: readonly string[]): string[] { + return [...new Set(slugs)]; } -/** The chain a client walks: the env override or the default, `CODEX_MODEL` first. */ -export function loadFallbackChain(): string[] { +/** The `CODEX_MODEL` pin and the `CODEX_MODEL_FALLBACK_CHAIN` replacement. */ +function operatorChoice(): { pinned: string | null; chain: string[] | null } { const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim(); - const fromEnv = envChain + const chain = envChain ? envChain .split(",") .map((s) => s.trim()) .filter(Boolean) - : [...DEFAULT_SLUG_FALLBACK_CHAIN]; - const pinned = process.env.CODEX_MODEL?.trim(); - const merged = pinned ? [pinned, ...fromEnv] : fromEnv; - // Stable de-duplication. - const seen = new Set(); - const result: string[] = []; - for (const slug of merged) { - if (seen.has(slug)) continue; - seen.add(slug); - result.push(slug); - } - return result; + : null; + return { + pinned: process.env.CODEX_MODEL?.trim() || null, + chain: chain && chain.length > 0 ? chain : null, + }; +} + +/** Whether the operator replaced the chain, so the account's list is not read. */ +function operatorReplacedCodexChain(): boolean { + return operatorChoice().chain !== null; +} + +/** + * The order a client walks for one sign-in. + * + * 1. The operator's choice, unchanged in meaning: `CODEX_MODEL` goes first; + * `CODEX_MODEL_FALLBACK_CHAIN` replaces everything after it, and the + * account's list is then not consulted at all. + * 2. Our preference order, kept to what the account is offered. + * 3. Every other offered model, in the list's own order (`priority`, as the + * Codex CLI sorts it), leaving out what the list hides from its picker + * (`visibility` other than `list`: review and specialist models), the + * specialist lines (`model_specialty`), and the skipped frontier tier. + * `supported_in_api` is not a filter: it describes API-key access, and + * the Codex CLI ignores it for a ChatGPT sign-in (`filter_by_auth`). + * 4. The rest of our preference order, last. A list that offers a model the + * backend then refuses must not be able to take Codex down, so the walk + * never holds fewer models than it did before the list was read; these + * only cost a refused request when everything ahead of them failed. + * + * Without a usable list (none read, a failed read, an empty one) the order is + * today's static walk. Models this sign-in was refused recently move to the + * very end, in their order, whatever step placed them. + */ +export function buildCodexSlugChain(input: { + offered: readonly CodexOfferedModel[] | null; + rejected?: ReadonlySet; +}): { chain: string[]; source: CodexChainSource } { + const { pinned, chain: operatorChain } = operatorChoice(); + let ordered: string[]; + let source: CodexChainSource; + if (operatorChain) { + ordered = operatorChain; + source = "operator"; + } else if (!input.offered || input.offered.length === 0) { + ordered = [...DEFAULT_SLUG_FALLBACK_CHAIN]; + source = "static"; + } else { + const usable = input.offered.filter( + (m) => m.listed && m.specialty === null && !SKIPPED_TIER.test(m.slug), + ); + const usableSlugs = new Set(usable.map((m) => m.slug)); + const preferred = DEFAULT_SLUG_FALLBACK_CHAIN.filter((s) => + usableSlugs.has(s), + ); + const others = usable + .map((m, index) => ({ m, index })) + .sort( + (a, b) => + (a.m.priority ?? Number.POSITIVE_INFINITY) - + (b.m.priority ?? Number.POSITIVE_INFINITY) || a.index - b.index, + ) + .map(({ m }) => m.slug); + ordered = [...preferred, ...others, ...DEFAULT_SLUG_FALLBACK_CHAIN]; + source = "live"; + } + const walk = dedupe(pinned ? [pinned, ...ordered] : ordered); + const rejected = input.rejected ?? new Set(); + return { + chain: [ + ...walk.filter((s) => !rejected.has(s)), + ...walk.filter((s) => rejected.has(s)), + ], + source, + }; +} + +/** How a client reads its account's model list. */ +export type CodexModelReader = typeof readCodexModelCatalog; + +/** + * The walk a client runs after its working slug, for one sign-in, with the + * outcome on the request's wide event (pinned shape, closed values): + * + * - `codex.models.chain`: where the order came from (`CodexChainSource`); + * - `codex.models.read`: `live`, `cache`, or `skipped` when the operator + * replaced the chain and the list was not asked; + * - `codex.models.failure`: why the list was unusable (`CodexCatalogFailure`) + * or null; + * - `codex.models.offered`: how many models the list offered (0 without one). + * + * `slugChain` and `readModels` are test seams: `slugChain` replaces the walk + * the way `CODEX_MODEL_FALLBACK_CHAIN` does (the list is not read, refused + * slugs still go last); `readModels` stands in for the backend read. + */ +export async function loadCodexAttemptChain(input: { + credential: CodexCredential; + accessToken: string; + slugChain?: readonly string[]; + readModels?: CodexModelReader; +}): Promise { + const rejected = rejectedCodexSlugs(input.credential.key); + if (input.slugChain && input.slugChain.length > 0) { + return [ + ...input.slugChain.filter((s) => !rejected.has(s)), + ...input.slugChain.filter((s) => rejected.has(s)), + ]; + } + let read: CodexCatalogRead | null = null; + if (!operatorReplacedCodexChain()) { + const reader = input.readModels ?? readCodexModelCatalog; + // The reader never rejects by contract; a bug in it must still leave + // the static walk rather than fail the request. + read = await reader(input.credential, { + accessToken: input.accessToken, + accountId: input.credential.accountId, + }).catch((): CodexCatalogRead => ({ + models: null, + from: "live", + failure: "network", + })); + } + const { chain, source } = buildCodexSlugChain({ + offered: read?.models ?? null, + rejected, + }); + annotate({ + meta: { + "codex.models.chain": source, + "codex.models.read": read?.from ?? "skipped", + "codex.models.failure": read?.failure ?? null, + "codex.models.offered": read?.models?.length ?? 0, + }, + }); + return chain; +} + +/** + * The slug the codex vision gate reasons about *before* a request runs, for + * one sign-in: its working slug if one is live, else the head of the order + * built from its last model list (or the static walk). Lets the vision + * resolvers decide whether codex can read images without standing up a + * client or making a network call. Best-effort: the routed slug may differ + * once the walk runs, which the runtime probe and the text / local OCR + * fallback cover. + */ +export function resolveCodexVisionSlug(credentialKey: string): string | null { + const cached = getCachedCodexSlug(credentialKey); + if (cached) return cached; + const { chain } = buildCodexSlugChain({ + offered: operatorReplacedCodexChain() + ? null + : cachedCodexModels(credentialKey), + rejected: rejectedCodexSlugs(credentialKey), + }); + return chain[0] ?? null; +} + +/** + * Whether this sign-in's Codex model reads images. When the account's list + * names the model's `input_modalities`, that answer wins; otherwise the model + * family decides (`supportsVisionForConfig`), as it did before the list. + */ +export function codexSignInReadsImages(credentialKey: string): boolean { + const slug = resolveCodexVisionSlug(credentialKey); + if (!slug) return false; + const offered = cachedCodexModels(credentialKey)?.find( + (m) => m.slug === slug, + ); + if (offered?.inputModalities) { + return offered.inputModalities.includes("image"); + } + return supportsVisionForConfig("codex", slug); +} + +/** + * Whether a provider chain entry can read images, for the vision resolvers. + * A Codex entry asks its own sign-in (`codex` the person's connection, + * `admin-codex` the operator-shared one); every other entry is decided by + * `model`'s family, as before. + */ +export function chainEntryReadsImages( + providerType: string, + userId: string, + model: string | null, +): boolean { + if (providerType === "codex") { + return codexSignInReadsImages(codexCredentialKey.user(userId)); + } + if (providerType === "admin-codex") { + return codexSignInReadsImages(codexCredentialKey.central); + } + return supportsVisionForConfig(providerType as VisionProviderType, model); } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index d589685ce..0dc9da751 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -5,7 +5,9 @@ import { bindReasoningEffort } from "./reasoning-effort"; import { prisma } from "@/lib/db"; import { openStoredKey } from "./stored-key"; import { isAnthropicBaseUrl } from "./anthropic-host"; -import { CodexClient, resolveCodexVisionSlug } from "./codex-client"; +import { CodexClient } from "./codex-client"; +import { codexCredentialKey } from "./codex-slug-cache"; +import { chainEntryReadsImages } from "./codex-slug-chain"; import { OpenAIClient } from "./openai-client"; import { AnthropicClient } from "./anthropic-client"; import { LocalOpenAICompatibleClient } from "./local-client"; @@ -45,10 +47,6 @@ import type { ProviderEntryPresence, ProviderPresence, } from "@/lib/ai/capabilities/resolve"; -import { - supportsVisionForConfig, - type VisionProviderType, -} from "./vision-capability"; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes @@ -602,6 +600,7 @@ async function resolveCodexProvider( return new CodexClient({ accessToken: active.accessToken, accountId: active.accountId, + credentialKey: codexCredentialKey.user(userId), onTokenRefresh: async () => { const current = await prisma.user.findUnique({ where: { id: userId }, @@ -690,6 +689,7 @@ async function resolveAdminCodexProvider(): Promise { return new CodexClient({ accessToken: active.accessToken, accountId: active.accountId, + credentialKey: codexCredentialKey.central, onTokenRefresh: async () => { const current = await prisma.appSettings.findUnique({ where: { id: "singleton" }, @@ -1151,22 +1151,18 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({ /** * Whether one chain entry's model can read an image. Mirrors the model each - * entry runs on (`src/lib/labs/ocr-capability.ts`): the Codex paths use the - * working Codex slug, the operator's slot uses the operator's model, every - * other entry the person's own. + * entry runs on (`src/lib/labs/ocr-capability.ts`): each Codex path asks its + * own sign-in, the operator's slot uses the operator's model, every other + * entry the person's own. */ function entryVision( providerType: string, + recordId: string, userModel: string | null, adminModel: string | null, ): boolean { - const model = - providerType === "codex" || providerType === "admin-codex" - ? resolveCodexVisionSlug() - : providerType === "admin-openai" - ? adminModel - : userModel; - return supportsVisionForConfig(providerType as VisionProviderType, model); + const model = providerType === "admin-openai" ? adminModel : userModel; + return chainEntryReadsImages(providerType, recordId, model); } /** @@ -1212,7 +1208,7 @@ export function probeProviderChain( const adminModel = settings?.adminAiModel ?? null; const vision = (providerType: string): ProviderEntryPresence => ({ providerType, - vision: entryVision(providerType, row.aiModel, adminModel), + vision: entryVision(providerType, recordId, row.aiModel, adminModel), }); if (policy === "deny") return NO_PRESENCE; diff --git a/src/lib/labs/__tests__/ocr-capability.test.ts b/src/lib/labs/__tests__/ocr-capability.test.ts index 3945f39a9..165b86c78 100644 --- a/src/lib/labs/__tests__/ocr-capability.test.ts +++ b/src/lib/labs/__tests__/ocr-capability.test.ts @@ -18,9 +18,6 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: vi.fn(), resolveProvider: vi.fn(), })); -vi.mock("@/lib/ai/codex-client", () => ({ - resolveCodexVisionSlug: vi.fn(() => "gpt-5"), -})); vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal: vi.fn(), })); diff --git a/src/lib/labs/__tests__/ocr-output-budget.test.ts b/src/lib/labs/__tests__/ocr-output-budget.test.ts index b57abe8c9..c7c365953 100644 --- a/src/lib/labs/__tests__/ocr-output-budget.test.ts +++ b/src/lib/labs/__tests__/ocr-output-budget.test.ts @@ -154,6 +154,7 @@ describe("Codex", () => { it("asks for no reasoning instead of the backend's default", async () => { const body = await sentBody( new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), @@ -175,6 +176,7 @@ describe("Codex", () => { ), ); const err = await new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), @@ -202,6 +204,7 @@ describe("Codex", () => { ), ); const err = await new CodexClient({ + credentialKey: "user:test", accessToken: "t", accountId: "a", onTokenRefresh: vi.fn(), diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index b2df0d8ef..e5787d2b5 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -18,13 +18,12 @@ import { import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; -import { resolveCodexVisionSlug } from "@/lib/ai/codex-client"; +import { chainEntryReadsImages } from "@/lib/ai/codex-slug-chain"; import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { supportsPdfForProvider, - supportsVisionForConfig, type VisionProviderType, } from "@/lib/ai/vision-capability"; import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr"; @@ -36,19 +35,19 @@ interface ModelContext { adminModel: string | null; } -/** The model that drives a chain entry's vision allowlist. */ -function modelForEntry( +/** Whether a chain entry's model can read an image. */ +function entryReadsImages( providerType: ProviderChainResolved["providerType"], + userId: string, ctx: ModelContext, -): string | null { - // Codex resolves its working slug at request time from the OAuth slug - // fallback chain — NOT from the user's `aiModel`. Use the cached/chain-head - // slug so the codex vision gate tests the model that actually runs. The - // operator-shared central Codex (`admin-codex`) rides the same slug chain. - if (providerType === "codex" || providerType === "admin-codex") { - return resolveCodexVisionSlug(); - } - return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel; +): boolean { + // Codex picks its model at request time from the sign-in's working slug + // and the models its account is offered, NOT from the user's `aiModel`. + // Each Codex path asks its own sign-in: the person's connection, or the + // operator-shared central one (`admin-codex`). + const model = + providerType === "admin-openai" ? ctx.adminModel : ctx.userModel; + return chainEntryReadsImages(providerType, userId, model); } /** @@ -126,8 +125,7 @@ export async function resolveVisionProvider( for (const entry of chain) { const providerType = entry.providerType as VisionProviderType; - const model = modelForEntry(entry.providerType, ctx); - if (supportsVisionForConfig(providerType, model)) { + if (entryReadsImages(entry.providerType, userId, ctx)) { return { chain, localOcrEnabled, From 2220855c8d99611bd947f22cbde4e23d77833cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:17:48 +0100 Subject: [PATCH 07/25] docs(ai): describe the Codex model list read and per-sign-in memory The protocol spec now documents the /models request, the fields read from it, the order the walk is built in, the per-sign-in cache and the codex.models.* annotations (the codex.slug.* keys it named never existed). The operator env comments say that CODEX_MODEL still goes first and that CODEX_MODEL_FALLBACK_CHAIN replaces the order and skips the list. Refs #1287 --- .env.production.example | 10 +++++----- docker-compose.yml | 10 +++++----- docs/codex-protocol-spec.md | 35 ++++++++++++++++++++++++++--------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.env.production.example b/.env.production.example index dc6d226c1..39868372c 100644 --- a/.env.production.example +++ b/.env.production.example @@ -375,11 +375,11 @@ API_TOKEN_HMAC_KEY="" # per-user BYO keys keep the pinned public-host posture either way. # ADMIN_AI_BASE_URL_ALLOWLIST="" -# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the -# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright -# (comma-separated, first match wins). Reach for these when the provider -# rotates a slug and requests start failing with 503 -- without them a -# rotation needs a new release to fix. +# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from +# the models its account is offered (read from the provider the way the Codex +# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one +# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright +# (comma-separated, first match wins) and the account's list is then not read. # CODEX_MODEL="" # CODEX_MODEL_FALLBACK_CHAIN="" diff --git a/docker-compose.yml b/docker-compose.yml index eb6dc7dcf..b7e7faef3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -347,11 +347,11 @@ x-healthlog-env: &healthlog_env # you run a gateway this instance should trust; it widens only the admin # setting, never the per-user BYO-key posture. ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}" - # Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug - # ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma- - # separated) to replace the chain outright. Reach for these when the - # provider rotates a slug and every request starts coming back 503 — - # without them a rotation needs a new release to fix. + # Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the + # models its account is offered, with a built-in order as the fallback. + # Set CODEX_MODEL to pin one slug ahead of that, or + # CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order + # outright; the account's list is then not read. CODEX_MODEL: "${CODEX_MODEL:-}" CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}" # Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 67b45d4bb..0baf43877 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -642,9 +642,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein." -To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series. +To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered. -**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default: +**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call: + +```http +GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1 +Host: chatgpt.com +Authorization: Bearer +ChatGPT-Account-ID: +originator: healthlog +``` + +The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch. + +**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family. + +**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default: ```ts const DEFAULT_SLUG_FALLBACK_CHAIN = [ @@ -662,9 +676,9 @@ Every default rung must be a model family the reasoning, sampling and vision gat The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows. -A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this. +A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up. -The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated. +The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated. **Trigger condition** (when to walk to the next slug): @@ -678,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep | `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) | | `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) | -**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write). +**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a credential reconnected to another account starts empty. Two sign-ins on different plans no longer overwrite each other's working slug. -**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds. +**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds. **All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings". -**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`. +**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug. -The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`. +The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`. --- @@ -781,7 +795,10 @@ All paths relative to `openai/codex` repo: - `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers - `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait - `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc. -- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum +- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire) +- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…` +- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity +- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL - `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums - `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers - `codex-rs/model-provider/src/auth.rs` — auth-provider selection From a40bd53d456ce795518bb2f3a3927f73062b2097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:20:28 +0100 Subject: [PATCH 08/25] feat(backup): offer an account when an upload's owner is gone An upload refused with backup.owner.missing no longer ends in an error toast. The backups console keeps the file and opens a dialog: a new account by default, which cannot hold anything yet, or an existing one, which the server checks as soon as it is picked and which the admin confirms by typing its username. Managed profiles and accounts pending deletion are not offered. The file is then sent again with that target, and a refusal stays in the dialog beside the choice it refuses. The new-account path says the account has no password yet and where to set its first one. Copy in every app language. --- messages/de.json | 29 +- messages/en.json | 29 +- messages/es.json | 29 +- messages/fr.json | 29 +- messages/it.json | 29 +- messages/ko.json | 29 +- messages/nl.json | 29 +- messages/pl.json | 29 +- messages/pt-BR.json | 29 +- src/components/admin/backups-section.tsx | 32 +- .../__tests__/backup-target-dialog.test.tsx | 219 +++++++++++ .../admin/backups/backup-target-dialog.tsx | 366 ++++++++++++++++++ .../admin/backups/use-backup-upload.ts | 87 ++++- src/lib/query-keys/admin.ts | 6 + 14 files changed, 936 insertions(+), 35 deletions(-) create mode 100644 src/components/admin/backups/__tests__/backup-target-dialog.test.tsx create mode 100644 src/components/admin/backups/backup-target-dialog.tsx diff --git a/messages/de.json b/messages/de.json index d71e38a98..d5851fb23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Download fehlgeschlagen", "downloadStarted": "Download gestartet", "uploadTitle": "Backup hochladen", - "uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.", + "uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Eine Kopie, deren Konto es auf diesem Server nicht mehr gibt, kann in ein anderes Konto gehen. Das Wiederherstellen ist ein eigener Schritt.", "uploadButton": "Datei hochladen", "uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.", "uploadSuccess": "Backup hochgeladen ({count} Datensätze)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.", "offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen." }, - "restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück." + "restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück.", + "target": { + "title": "In ein anderes Konto wiederherstellen", + "description": "{file} gehört zu einem Konto, das es auf diesem Server nicht mehr gibt. Wähle das Konto, in das die Datensätze gehen. Eine Wiederherstellung ersetzt die Datensätze eines Kontos, deshalb kann sie nur ein Konto ohne Datensätze aufnehmen. Die Datei wird dafür noch einmal hochgeladen.", + "choiceLabel": "Wohin die Datensätze gehen", + "newAccount": "Neues Konto", + "existingAccount": "Bestehendes Konto", + "usernameLabel": "Benutzername für das neue Konto", + "usernameInvalid": "3 bis 30 Buchstaben, Ziffern, _ oder -, nicht mit „managed-“ am Anfang.", + "newAccountHint": "Das Konto wird ohne Passwort und ohne E-Mail angelegt. Bevor du es übergibst, setze sein erstes Passwort unter Nutzer mit „Passwort zurücksetzen“.", + "accountLabel": "Konto", + "accountPlaceholder": "Konto wählen", + "checking": "Konto wird geprüft …", + "eligible": "Dieses Konto hat noch keine Datensätze und kann die Kopie aufnehmen.", + "confirmLabel": "Tippe {username} zum Bestätigen", + "submit": "In dieses Konto hochladen", + "submitting": "Wird hochgeladen …", + "success": "Backup für {username} gespeichert ({count} Datensätze). Stelle es über seine Zeile wieder her.", + "failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.", + "failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.", + "failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.", + "failureNotFound": "Dieses Konto gibt es nicht mehr.", + "failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.", + "failureUsernameTaken": "Dieser Benutzername ist schon vergeben.", + "failureOwnerExists": "Das Konto, zu dem diese Datei gehört, ist auf diesem Server, und die Datei wird in dieses Konto wiederhergestellt. Lade sie noch einmal hoch, ohne ein Konto zu wählen." + } }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 49c2d6fcd..121620264 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Download failed", "downloadStarted": "Download started", "uploadTitle": "Upload backup", - "uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.", + "uploadDescription": "Check an account copy and store it beside this server's copies. A copy whose account is no longer on this server can go into another account. Restoring is a separate step.", "uploadButton": "Upload file", "uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.", "uploadSuccess": "Backup uploaded ({count} records)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "The newest dump in the bucket is from {when}.", "offhostNeverUploaded": "No database dump has reached the bucket yet." }, - "restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back." + "restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back.", + "target": { + "title": "Restore into another account", + "description": "{file} belongs to an account that is no longer on this server. Choose the account its records go into. A restore replaces an account's records, so only an account without records can take them. The file is uploaded once more.", + "choiceLabel": "Where the records go", + "newAccount": "New account", + "existingAccount": "Existing account", + "usernameLabel": "Username for the new account", + "usernameInvalid": "3 to 30 letters, digits, _ or -, not starting with \"managed-\".", + "newAccountHint": "The account is created without a password or email. Before you hand it over, set its first password with Reset password under Users.", + "accountLabel": "Account", + "accountPlaceholder": "Choose an account", + "checking": "Checking the account…", + "eligible": "This account holds no records yet and can take the copy.", + "confirmLabel": "Type {username} to confirm", + "submit": "Upload into this account", + "submitting": "Uploading…", + "success": "Backup stored for {username} ({count} records). Restore it from its row.", + "failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.", + "failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.", + "failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.", + "failureNotFound": "This account no longer exists.", + "failureUnconfirmed": "Type the account's username exactly to confirm.", + "failureUsernameTaken": "This username is already taken.", + "failureOwnerExists": "The account this file belongs to is on this server, and the file restores into that account. Upload it again without choosing an account." + } }, "danger-zone": { "title": "Danger zone", diff --git a/messages/es.json b/messages/es.json index 664fbb6b2..154478df0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8217,7 +8217,7 @@ "downloadFailed": "La descarga falló", "downloadStarted": "Descarga iniciada", "uploadTitle": "Subir copia de seguridad", - "uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.", + "uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. Una copia cuya cuenta ya no está en este servidor puede ir a otra cuenta. Restaurar es un paso aparte.", "uploadButton": "Subir archivo", "uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.", "uploadSuccess": "Copia de seguridad subida ({count} registros)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "El volcado más reciente del bucket es del {when}.", "offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket." }, - "restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven." + "restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven.", + "target": { + "title": "Restaurar en otra cuenta", + "description": "{file} pertenece a una cuenta que ya no está en este servidor. Elige la cuenta a la que van sus registros. Una restauración reemplaza los registros de una cuenta, así que solo una cuenta sin registros puede recibirlos. El archivo se sube una vez más.", + "choiceLabel": "Adónde van los registros", + "newAccount": "Cuenta nueva", + "existingAccount": "Cuenta existente", + "usernameLabel": "Nombre de usuario de la cuenta nueva", + "usernameInvalid": "De 3 a 30 letras, números, _ o -, sin empezar por «managed-».", + "newAccountHint": "La cuenta se crea sin contraseña ni correo. Antes de entregarla, pon su primera contraseña con Restablecer contraseña en Usuarios.", + "accountLabel": "Cuenta", + "accountPlaceholder": "Elige una cuenta", + "checking": "Comprobando la cuenta…", + "eligible": "Esta cuenta aún no tiene registros y puede recibir la copia.", + "confirmLabel": "Escribe {username} para confirmar", + "submit": "Subir a esta cuenta", + "submitting": "Subiendo…", + "success": "Copia guardada para {username} ({count} registros). Restáurala desde su fila.", + "failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.", + "failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.", + "failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.", + "failureNotFound": "Esta cuenta ya no existe.", + "failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.", + "failureUsernameTaken": "Este nombre de usuario ya está en uso.", + "failureOwnerExists": "La cuenta a la que pertenece este archivo está en este servidor, y el archivo se restaura en esa cuenta. Súbelo de nuevo sin elegir una cuenta." + } }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index c48261e70..94f74c1d7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Échec du téléchargement", "downloadStarted": "Téléchargement démarré", "uploadTitle": "Téléverser une sauvegarde", - "uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.", + "uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Une copie dont le compte n'existe plus sur ce serveur peut aller dans un autre compte. La restauration est une étape distincte.", "uploadButton": "Téléverser un fichier", "uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.", "uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "Le dump le plus récent du bucket date du {when}.", "offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket." }, - "restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas." + "restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas.", + "target": { + "title": "Restaurer dans un autre compte", + "description": "{file} appartient à un compte qui n'existe plus sur ce serveur. Choisissez le compte qui recevra ses enregistrements. Une restauration remplace les enregistrements d'un compte : seul un compte sans enregistrements peut donc les recevoir. Le fichier est téléversé une nouvelle fois.", + "choiceLabel": "Destination des enregistrements", + "newAccount": "Nouveau compte", + "existingAccount": "Compte existant", + "usernameLabel": "Nom d'utilisateur du nouveau compte", + "usernameInvalid": "De 3 à 30 lettres, chiffres, _ ou -, sans commencer par « managed- ».", + "newAccountHint": "Le compte est créé sans mot de passe ni e-mail. Avant de le remettre, définissez son premier mot de passe avec Réinitialiser le mot de passe, sous Utilisateurs.", + "accountLabel": "Compte", + "accountPlaceholder": "Choisissez un compte", + "checking": "Vérification du compte…", + "eligible": "Ce compte n'a encore aucun enregistrement et peut recevoir la copie.", + "confirmLabel": "Tapez {username} pour confirmer", + "submit": "Téléverser dans ce compte", + "submitting": "Téléversement…", + "success": "Sauvegarde enregistrée pour {username} ({count} enregistrements). Restaurez-la depuis sa ligne.", + "failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.", + "failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.", + "failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.", + "failureNotFound": "Ce compte n'existe plus.", + "failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.", + "failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.", + "failureOwnerExists": "Le compte auquel appartient ce fichier est sur ce serveur, et le fichier se restaure dans ce compte. Téléversez-le à nouveau sans choisir de compte." + } }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index f44262fe3..e6235ce0d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Download non riuscito", "downloadStarted": "Download avviato", "uploadTitle": "Carica backup", - "uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.", + "uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. Una copia il cui account non è più su questo server può andare in un altro account. Il ripristino è un passaggio a parte.", "uploadButton": "Carica file", "uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.", "uploadSuccess": "Backup caricato ({count} record)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "Il dump più recente nel bucket è del {when}.", "offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket." }, - "restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano." + "restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano.", + "target": { + "title": "Ripristina in un altro account", + "description": "{file} appartiene a un account che non è più su questo server. Scegli l’account in cui vanno i suoi record. Un ripristino sostituisce i record di un account, quindi solo un account senza record può accoglierli. Il file viene caricato un’altra volta.", + "choiceLabel": "Dove vanno i record", + "newAccount": "Nuovo account", + "existingAccount": "Account esistente", + "usernameLabel": "Nome utente del nuovo account", + "usernameInvalid": "Da 3 a 30 lettere, cifre, _ o -, senza iniziare con “managed-”.", + "newAccountHint": "L’account viene creato senza password né email. Prima di consegnarlo, imposta la sua prima password con Reimposta password in Utenti.", + "accountLabel": "Account", + "accountPlaceholder": "Scegli un account", + "checking": "Controllo dell’account…", + "eligible": "Questo account non ha ancora record e può accogliere la copia.", + "confirmLabel": "Digita {username} per confermare", + "submit": "Carica in questo account", + "submitting": "Caricamento…", + "success": "Backup salvato per {username} ({count} record). Ripristinalo dalla sua riga.", + "failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.", + "failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.", + "failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.", + "failureNotFound": "Questo account non esiste più.", + "failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.", + "failureUsernameTaken": "Questo nome utente è già in uso.", + "failureOwnerExists": "L’account a cui appartiene questo file è su questo server, e il file si ripristina in quell’account. Caricalo di nuovo senza scegliere un account." + } }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 01cea67ec..0bf626cac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8192,7 +8192,7 @@ "downloadFailed": "다운로드하지 못했어요", "downloadStarted": "다운로드를 시작했어요", "uploadTitle": "백업 업로드", - "uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.", + "uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 계정이 이 서버에 더 이상 없는 사본은 다른 계정으로 보낼 수 있어요. 복원은 별도 단계예요.", "uploadButton": "파일 업로드", "uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.", "uploadSuccess": "백업을 업로드했어요 (기록 {count}개)", @@ -8351,7 +8351,32 @@ "offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.", "offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다." }, - "restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요." + "restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요.", + "target": { + "title": "다른 계정으로 복원", + "description": "{file}은(는) 이 서버에 더 이상 없는 계정의 파일이에요. 기록을 넣을 계정을 골라 주세요. 복원은 계정의 기록을 바꿔 쓰기 때문에 기록이 없는 계정만 받을 수 있어요. 파일은 한 번 더 업로드돼요.", + "choiceLabel": "기록을 넣을 곳", + "newAccount": "새 계정", + "existingAccount": "기존 계정", + "usernameLabel": "새 계정의 사용자 이름", + "usernameInvalid": "영문자, 숫자, _ 또는 - 3~30자이고, \"managed-\"로 시작하면 안 돼요.", + "newAccountHint": "계정은 비밀번호와 이메일 없이 만들어져요. 넘겨주기 전에 사용자에서 비밀번호 재설정으로 첫 비밀번호를 정해 주세요.", + "accountLabel": "계정", + "accountPlaceholder": "계정을 골라 주세요", + "checking": "계정을 확인하는 중…", + "eligible": "이 계정에는 아직 기록이 없어서 사본을 받을 수 있어요.", + "confirmLabel": "확인하려면 {username}을(를) 입력해 주세요", + "submit": "이 계정으로 업로드", + "submitting": "업로드 중…", + "success": "{username}의 백업을 저장했어요 (기록 {count}개). 해당 행에서 복원해 주세요.", + "failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.", + "failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.", + "failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.", + "failureNotFound": "이 계정은 더 이상 없어요.", + "failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.", + "failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.", + "failureOwnerExists": "이 파일의 계정이 이 서버에 있어서 파일은 그 계정으로 복원돼요. 계정을 고르지 말고 다시 업로드해 주세요." + } }, "danger-zone": { "title": "위험 구역", diff --git a/messages/nl.json b/messages/nl.json index 9fe4b8f08..dae584aa7 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Downloaden mislukt", "downloadStarted": "Download gestart", "uploadTitle": "Back-up uploaden", - "uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.", + "uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Een kopie waarvan het account niet meer op deze server staat, kan naar een ander account. Terugzetten is een aparte stap.", "uploadButton": "Bestand uploaden", "uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.", "uploadSuccess": "Back-up geüpload ({count} records)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "De nieuwste dump in de bucket is van {when}.", "offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen." }, - "restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug." + "restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug.", + "target": { + "title": "In een ander account terugzetten", + "description": "{file} hoort bij een account dat niet meer op deze server staat. Kies het account waar de records naartoe gaan. Terugzetten vervangt de records van een account, dus alleen een account zonder records kan ze opnemen. Het bestand wordt nog een keer geüpload.", + "choiceLabel": "Waar de records naartoe gaan", + "newAccount": "Nieuw account", + "existingAccount": "Bestaand account", + "usernameLabel": "Gebruikersnaam voor het nieuwe account", + "usernameInvalid": "3 tot 30 letters, cijfers, _ of -, niet beginnend met ‘managed-’.", + "newAccountHint": "Het account wordt zonder wachtwoord en zonder e-mail aangemaakt. Stel het eerste wachtwoord in met Wachtwoord resetten onder Gebruikers voordat je het overdraagt.", + "accountLabel": "Account", + "accountPlaceholder": "Kies een account", + "checking": "Account wordt gecontroleerd…", + "eligible": "Dit account heeft nog geen records en kan de kopie opnemen.", + "confirmLabel": "Typ {username} om te bevestigen", + "submit": "Naar dit account uploaden", + "submitting": "Uploaden…", + "success": "Back-up opgeslagen voor {username} ({count} records). Zet hem terug vanuit zijn rij.", + "failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.", + "failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.", + "failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.", + "failureNotFound": "Dit account bestaat niet meer.", + "failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.", + "failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.", + "failureOwnerExists": "Het account waar dit bestand bij hoort, staat op deze server, en het bestand wordt in dat account teruggezet. Upload het opnieuw zonder een account te kiezen." + } }, "danger-zone": { "title": "Gevarenzone", diff --git a/messages/pl.json b/messages/pl.json index 94fc0bcaf..7439443ef 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Pobieranie nie powiodło się", "downloadStarted": "Rozpoczęto pobieranie", "uploadTitle": "Prześlij kopię zapasową", - "uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.", + "uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Kopia, której konta nie ma już na tym serwerze, może trafić do innego konta. Przywracanie to osobny krok.", "uploadButton": "Prześlij plik", "uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.", "uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.", "offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych." }, - "restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają." + "restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają.", + "target": { + "title": "Przywróć do innego konta", + "description": "{file} należy do konta, którego nie ma już na tym serwerze. Wybierz konto, do którego trafią jego rekordy. Przywracanie zastępuje rekordy konta, więc przyjąć je może tylko konto bez rekordów. Plik zostanie przesłany jeszcze raz.", + "choiceLabel": "Dokąd trafią rekordy", + "newAccount": "Nowe konto", + "existingAccount": "Istniejące konto", + "usernameLabel": "Nazwa użytkownika nowego konta", + "usernameInvalid": "Od 3 do 30 liter, cyfr, _ lub -, bez „managed-” na początku.", + "newAccountHint": "Konto zostanie utworzone bez hasła i bez adresu e-mail. Zanim je przekażesz, ustaw jego pierwsze hasło przyciskiem Zresetuj hasło w sekcji Użytkownicy.", + "accountLabel": "Konto", + "accountPlaceholder": "Wybierz konto", + "checking": "Sprawdzanie konta…", + "eligible": "To konto nie ma jeszcze rekordów i może przyjąć kopię.", + "confirmLabel": "Wpisz {username}, aby potwierdzić", + "submit": "Prześlij do tego konta", + "submitting": "Przesyłanie…", + "success": "Kopia zapisana dla {username} ({count} rekordów). Przywróć ją z jej wiersza.", + "failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.", + "failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.", + "failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.", + "failureNotFound": "To konto już nie istnieje.", + "failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.", + "failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.", + "failureOwnerExists": "Konto, do którego należy ten plik, jest na tym serwerze, a plik przywraca się do tego konta. Prześlij go ponownie bez wybierania konta." + } }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 0e3fd7d56..df289a58d 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8217,7 +8217,7 @@ "downloadFailed": "Falha no download", "downloadStarted": "Download iniciado", "uploadTitle": "Enviar backup", - "uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.", + "uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. Uma cópia cuja conta não está mais neste servidor pode ir para outra conta. Restaurar é uma etapa separada.", "uploadButton": "Enviar arquivo", "uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.", "uploadSuccess": "Backup enviado ({count} registros)", @@ -8376,7 +8376,32 @@ "offhostLastUpload": "O dump mais recente no bucket é de {when}.", "offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda." }, - "restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam." + "restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam.", + "target": { + "title": "Restaurar em outra conta", + "description": "{file} pertence a uma conta que não está mais neste servidor. Escolha a conta para onde vão os registros. Uma restauração substitui os registros de uma conta, então só uma conta sem registros pode recebê-los. O arquivo é enviado mais uma vez.", + "choiceLabel": "Para onde vão os registros", + "newAccount": "Conta nova", + "existingAccount": "Conta existente", + "usernameLabel": "Nome de usuário da conta nova", + "usernameInvalid": "De 3 a 30 letras, números, _ ou -, sem começar com “managed-”.", + "newAccountHint": "A conta é criada sem senha e sem e-mail. Antes de entregá-la, defina a primeira senha com Redefinir senha em Usuários.", + "accountLabel": "Conta", + "accountPlaceholder": "Escolha uma conta", + "checking": "Verificando a conta…", + "eligible": "Esta conta ainda não tem registros e pode receber a cópia.", + "confirmLabel": "Digite {username} para confirmar", + "submit": "Enviar para esta conta", + "submitting": "Enviando…", + "success": "Backup guardado para {username} ({count} registros). Restaure-o pela linha dele.", + "failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.", + "failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.", + "failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.", + "failureNotFound": "Esta conta não existe mais.", + "failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.", + "failureUsernameTaken": "Este nome de usuário já está em uso.", + "failureOwnerExists": "A conta a que este arquivo pertence está neste servidor, e o arquivo é restaurado nessa conta. Envie-o de novo sem escolher uma conta." + } }, "danger-zone": { "title": "Zona de perigo", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index e1513c973..a337889b3 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -15,7 +15,10 @@ import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react"; import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { + ReproofCancelledError, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; import { SettingsCard } from "@/components/settings/settings-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -29,6 +32,7 @@ import type { BackupRow, BackupsList } from "@/types/backups"; import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status"; import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows"; import { DatabaseDumpRow } from "./backups/database-dump-row"; +import { BackupTargetDialog } from "./backups/backup-target-dialog"; import { BackupUploadCard } from "./backups/backup-upload-card"; import { RestoreRefusalNotice, @@ -55,8 +59,14 @@ export function BackupsSection() { // a fresh proof unless this session signed in or re-proved within minutes. const recentProof = useRecentProof(); const { downloadingId, handleDownload } = useBackupDownload(recentProof); - const { upload, fileInputRef, handleFileChange } = - useBackupUpload(recentProof); + const { + upload, + fileInputRef, + handleFileChange, + orphan, + uploadInto, + closeOrphan, + } = useBackupUpload(recentProof); const { restoreJobs, dismissedRestores, @@ -127,10 +137,24 @@ export function BackupsSection() { ) : null} + {/* Mounted per refused file, so every file starts from a clean form. */} + {orphan ? ( + uploadInto.mutate(target)} + onClose={closeOrphan} + /> + ) : null} {visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => ( ): AdminUser { + return { + id: "u1", + username: "returning-person", + email: null, + role: "USER", + createdAt: "2026-10-01T00:00:00.000Z", + mfaEnforced: false, + documentQuotaBytes: null, + passkeyCount: 0, + managedProfile: false, + deletion: null, + ...overrides, + }; +} + +const USERS = [ + user({ id: "u1", username: "returning-person", email: "r@example.test" }), + user({ id: "u2", username: "managed-cafe01", managedProfile: true }), + user({ + id: "u3", + username: "leaving", + deletion: { requestedAt: "x", purgeAt: null, requestedBy: "self" }, + }), + user({ id: "u4", username: "operator", role: "ADMIN" }), +]; + +const ELIGIBLE: BackupTargetCheck = { + id: "u1", + username: "returning-person", + eligible: true, + reason: null, + models: [], +}; + +function render( + draft: BackupTargetDraft, + check: { data?: BackupTargetCheck; loading: boolean; error: unknown } = { + loading: false, + error: null, + }, +): string { + return renderToStaticMarkup( + + {}} + disabled={false} + users={USERS} + check={check} + /> + , + ); +} + +describe("pickableAccounts", () => { + it("offers neither managed profiles nor accounts pending deletion", () => { + expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([ + "returning-person", + "operator", + ]); + }); +}); + +describe("readyTarget", () => { + it("takes a new account with a valid username nobody has", () => { + expect( + readyTarget( + { ...EMPTY_TARGET_DRAFT, username: " back-again " }, + USERS, + undefined, + ), + ).toEqual({ into: "new", username: "back-again" }); + }); + + it("refuses a new username that is taken, malformed or reads as a managed profile", () => { + for (const username of ["operator", "no spaces", "ab", "managed-x1"]) { + expect( + readyTarget({ ...EMPTY_TARGET_DRAFT, username }, USERS, undefined), + username, + ).toBeNull(); + } + }); + + it("takes an existing account only with the server's yes and its username typed", () => { + const draft: BackupTargetDraft = { + into: "existing", + username: "", + userId: "u1", + confirm: "returning-person", + }; + expect(readyTarget(draft, USERS, ELIGIBLE)).toEqual({ + into: "existing", + userId: "u1", + confirm: "returning-person", + }); + expect(readyTarget(draft, USERS, undefined)).toBeNull(); + expect( + readyTarget(draft, USERS, { + ...ELIGIBLE, + eligible: false, + reason: "holds_records", + models: ["Measurement"], + }), + ).toBeNull(); + // A yes for another account does not carry over. + expect(readyTarget(draft, USERS, { ...ELIGIBLE, id: "u4" })).toBeNull(); + expect( + readyTarget({ ...draft, confirm: "Returning-Person" }, USERS, ELIGIBLE), + ).toBeNull(); + }); +}); + +describe("targetUploadFailureKey", () => { + it("names each refusal the upload answers with, and falls back for the rest", () => { + const key = (errorCode?: string) => + targetUploadFailureKey( + new ApiError("x", 409, errorCode ? { errorCode } : undefined), + ); + expect(key("backup.target.holds_records")).toBe( + "admin.section.backups.target.failureHoldsRecords", + ); + expect(key("backup.target.managed_profile")).toBe( + "admin.section.backups.target.failureManagedProfile", + ); + expect(key("backup.target.pending_deletion")).toBe( + "admin.section.backups.target.failurePendingDeletion", + ); + expect(key("backup.target.not_found")).toBe( + "admin.section.backups.target.failureNotFound", + ); + expect(key("backup.target.unconfirmed")).toBe( + "admin.section.backups.target.failureUnconfirmed", + ); + expect(key("backup.target.username_taken")).toBe( + "admin.section.backups.target.failureUsernameTaken", + ); + expect(key("backup.owner.exists")).toBe( + "admin.section.backups.target.failureOwnerExists", + ); + expect(key()).toBe("admin.section.backups.uploadFailed"); + expect(targetUploadFailureKey(new Error("offline"))).toBe( + "admin.section.backups.uploadFailed", + ); + }); +}); + +describe("", () => { + it("starts on a new account, with the sign-in note", () => { + const html = render(EMPTY_TARGET_DRAFT); + expect(html).toContain('data-testid="backup-target-username"'); + expect(html).toContain("created without a password or email"); + expect(html).not.toContain('data-testid="backup-target-account"'); + }); + + it("says a taken username is taken", () => { + const html = render({ ...EMPTY_TARGET_DRAFT, username: "operator" }); + expect(html).toContain("This username is already taken."); + }); + + it("offers only pickable accounts, and asks for the username once the server says yes", () => { + const html = render( + { into: "existing", username: "", userId: "u1", confirm: "" }, + { data: ELIGIBLE, loading: false, error: null }, + ); + expect(html).toContain("returning-person (r@example.test)"); + expect(html).toContain(">operator<"); + expect(html).not.toContain("managed-cafe01"); + expect(html).not.toContain(">leaving<"); + expect(html).toContain('data-slot="backup-target-eligible"'); + expect(html).toContain("Type returning-person to confirm"); + expect(html).toContain('data-testid="backup-target-confirm"'); + }); + + it("says why an account that holds records cannot take the copy, and asks nothing more", () => { + const html = render( + { into: "existing", username: "", userId: "u1", confirm: "" }, + { + data: { + ...ELIGIBLE, + eligible: false, + reason: "holds_records", + models: ["Measurement"], + }, + loading: false, + error: null, + }, + ); + expect(html).toContain('data-slot="backup-target-refused"'); + expect(html).toContain("This account already holds records."); + expect(html).not.toContain('data-testid="backup-target-confirm"'); + }); +}); diff --git a/src/components/admin/backups/backup-target-dialog.tsx b/src/components/admin/backups/backup-target-dialog.tsx new file mode 100644 index 000000000..2e5e52584 --- /dev/null +++ b/src/components/admin/backups/backup-target-dialog.tsx @@ -0,0 +1,366 @@ +import { useQuery } from "@tanstack/react-query"; +import { Loader2, UserPlus } from "lucide-react"; +import { useState } from "react"; + +import type { AdminUser } from "@/components/admin/_shared"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Segmented } from "@/components/ui/segmented"; +import { ApiError, apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { accountUsernameSchema } from "@/lib/validations/auth"; +import type { BackupUploadTarget } from "@/lib/validations/backup-upload-target"; + +/** What the admin has filled in so far. */ +export interface BackupTargetDraft { + into: "new" | "existing"; + username: string; + userId: string; + confirm: string; +} + +export const EMPTY_TARGET_DRAFT: BackupTargetDraft = { + into: "new", + username: "", + userId: "", + confirm: "", +}; + +/** The server's answer to "may this account take the copy?". */ +export interface BackupTargetCheck { + id: string; + username: string; + eligible: boolean; + reason: string | null; + models: string[]; +} + +/** + * The accounts the picker offers: not a managed profile (its Guardians run + * it) and not pending deletion. Whether one holds records is the server's to + * say, per account, once it is picked. + */ +export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] { + return users.filter((user) => !user.managedProfile && user.deletion === null); +} + +/** + * The target the draft describes, or null while it is not one the upload + * would take: a new account needs a valid username nobody has, an existing + * one needs the server's yes and its username typed exactly. + */ +export function readyTarget( + draft: BackupTargetDraft, + users: readonly AdminUser[], + check: BackupTargetCheck | undefined, +): BackupUploadTarget | null { + if (draft.into === "new") { + const username = draft.username.trim(); + if (!accountUsernameSchema.safeParse(username).success) return null; + if (users.some((user) => user.username === username)) return null; + return { into: "new", username }; + } + const user = users.find((candidate) => candidate.id === draft.userId); + if (!user || !check || check.id !== user.id || !check.eligible) return null; + if (draft.confirm.trim() !== user.username) return null; + return { into: "existing", userId: user.id, confirm: user.username }; +} + +/** The sentence for a refusal, by the code the server answered with. */ +export function targetFailureKey(code: string): string { + switch (code) { + case "backup.target.holds_records": + return "admin.section.backups.target.failureHoldsRecords"; + case "backup.target.managed_profile": + return "admin.section.backups.target.failureManagedProfile"; + case "backup.target.pending_deletion": + return "admin.section.backups.target.failurePendingDeletion"; + case "backup.target.not_found": + return "admin.section.backups.target.failureNotFound"; + case "backup.target.unconfirmed": + return "admin.section.backups.target.failureUnconfirmed"; + case "backup.target.username_taken": + return "admin.section.backups.target.failureUsernameTaken"; + case "backup.owner.exists": + return "admin.section.backups.target.failureOwnerExists"; + default: + return "admin.section.backups.uploadFailed"; + } +} + +/** The sentence for a failed upload or check. */ +export function targetUploadFailureKey(error: unknown): string { + return targetFailureKey( + error instanceof ApiError && typeof error.meta?.errorCode === "string" + ? error.meta.errorCode + : "", + ); +} + +/** + * The form inside the dialog, apart from it so it renders on its own: where + * the copy of an account that is no longer on this server goes. A new account + * by default, since it cannot hold anything yet; an existing one when the + * person has signed up again, checked by the server as soon as it is picked. + */ +export function BackupTargetForm({ + draft, + onChange, + disabled, + users, + check, +}: { + draft: BackupTargetDraft; + onChange: (next: BackupTargetDraft) => void; + disabled: boolean; + /** Every account; null while the list loads. */ + users: readonly AdminUser[] | null; + /** The server's check of the picked account. */ + check: { data?: BackupTargetCheck; loading: boolean; error: unknown }; +}) { + const { t } = useTranslations(); + const accounts = pickableAccounts(users ?? []); + const chosen = accounts.find((user) => user.id === draft.userId); + const username = draft.username.trim(); + const usernameTaken = (users ?? []).some( + (user) => user.username === username, + ); + const usernameInvalid = + username !== "" && !accountUsernameSchema.safeParse(username).success; + + return ( +
+ onChange({ ...draft, into })} + options={[ + { value: "new", label: t("admin.section.backups.target.newAccount") }, + { + value: "existing", + label: t("admin.section.backups.target.existingAccount"), + }, + ]} + /> + + {draft.into === "new" ? ( +
+ + onChange({ ...draft, username: e.target.value })} + autoComplete="off" + spellCheck={false} + aria-invalid={usernameInvalid || usernameTaken} + /> + {usernameTaken ? ( +

+ {t("admin.section.backups.target.failureUsernameTaken")} +

+ ) : usernameInvalid ? ( +

+ {t("admin.section.backups.target.usernameInvalid")} +

+ ) : null} +

+ {t("admin.section.backups.target.newAccountHint")} +

+
+ ) : ( +
+ + + onChange({ ...draft, userId: e.target.value, confirm: "" }) + } + > + + {accounts.map((user) => ( + + ))} + + {chosen && check.loading ? ( +

+ {t("admin.section.backups.target.checking")} +

+ ) : null} + {chosen && check.error ? ( +

+ {t(targetUploadFailureKey(check.error))} +

+ ) : null} + {chosen && check.data && !check.data.eligible ? ( +

+ {t(targetFailureKey(`backup.target.${check.data.reason}`))} +

+ ) : null} + {chosen && check.data?.eligible ? ( + <> +

+ {t("admin.section.backups.target.eligible")} +

+ + + onChange({ ...draft, confirm: e.target.value }) + } + autoComplete="off" + spellCheck={false} + placeholder={chosen.username} + /> + + ) : null} +
+ )} +
+ ); +} + +/** + * Shown when an upload was refused because the account the file belongs to + * is not on this server. Choosing a target sends the file again with it; the + * server rewrites the file's owner to that account as it stores it. + */ +export function BackupTargetDialog({ + file, + pending, + error, + onSubmit, + onClose, +}: { + /** The refused file; the dialog is open while there is one. */ + file: File | null; + pending: boolean; + /** The last refusal of an upload into a target, shown in the dialog. */ + error: unknown; + onSubmit: (target: BackupUploadTarget) => void; + onClose: () => void; +}) { + const { t } = useTranslations(); + const [draft, setDraft] = useState(EMPTY_TARGET_DRAFT); + const users = useQuery({ + queryKey: queryKeys.adminUsers(), + queryFn: () => apiGet("/api/admin/users"), + enabled: file !== null, + }); + const check = useQuery({ + queryKey: queryKeys.adminBackupTarget(draft.userId), + queryFn: () => + apiGet( + `/api/admin/backups/targets/${encodeURIComponent(draft.userId)}`, + ), + enabled: file !== null && draft.into === "existing" && draft.userId !== "", + retry: false, + }); + const target = readyTarget(draft, users.data ?? [], check.data); + + return ( + { + if (next || pending) return; + onClose(); + }} + > + + + + {t("admin.section.backups.target.title")} + + + {t("admin.section.backups.target.description", { + file: file?.name ?? "", + })} + + + {file ? ( + + ) : null} + {error && !pending ? ( +

+ {t(targetUploadFailureKey(error))} +

+ ) : null} + + + {t("common.cancel")} + + + +
+
+ ); +} diff --git a/src/components/admin/backups/use-backup-upload.ts b/src/components/admin/backups/use-backup-upload.ts index 30c7f7efc..97f263bf6 100644 --- a/src/components/admin/backups/use-backup-upload.ts +++ b/src/components/admin/backups/use-backup-upload.ts @@ -1,13 +1,18 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRef, type ChangeEvent } from "react"; +import { useRef, useState, type ChangeEvent } from "react"; import { toast } from "sonner"; import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; -import { apiFetch, apiGet } from "@/lib/api/api-fetch"; +import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { + BACKUP_OWNER_MISSING_CODE, + backupUploadTargetQuery, + type BackupUploadTarget, +} from "@/lib/validations/backup-upload-target"; /** * The upload body for a backup file: the file itself when it is already @@ -28,9 +33,11 @@ async function uploadBody(file: File): Promise { * and a large export (hundreds of MB of JSON) shrinks about tenfold. No request * timeout, because a file of that size takes longer to check and store than * the default allows. The body is built here, per call, so a retry after a - * re-proof sends the file again rather than a spent stream. + * re-proof sends the file again rather than a spent stream. With a target, the + * copy goes into that account instead of the one the file names, which is + * how the copy of an account no longer on this server comes back. */ -async function uploadOnce(file: File) { +async function uploadOnce(file: File, target?: BackupUploadTarget) { const body = await uploadBody(file); return apiFetch<{ id: string; @@ -65,12 +72,17 @@ async function uploadOnce(file: File) { customMetricEntries?: number; intradayProfiles?: number; }; - }>("/api/admin/backups/upload", { - method: "POST", - body, - headers: { "Content-Type": "application/gzip" }, - signal: null, - }); + }>( + target + ? `/api/admin/backups/upload?${backupUploadTargetQuery(target)}` + : "/api/admin/backups/upload", + { + method: "POST", + body, + headers: { "Content-Type": "application/gzip" }, + signal: null, + }, + ); } export type UploadSummary = Awaited>["summary"]; @@ -106,9 +118,19 @@ export function uploadedRecordCount(summary: UploadSummary): number { ); } +/** Whether an upload was refused because the file's account is not here. */ +export function isOwnerMissing(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === BACKUP_OWNER_MISSING_CODE + ); +} + /** * The single-file upload flow: a fresh proof first, then the file, and the - * file input reset after either outcome. + * file input reset after either outcome. A file whose account is not on this + * server is kept (`orphan`) for the target dialog, which sends it again into + * the account the admin picks (`uploadInto`). */ export function useBackupUpload( recentProof: ReturnType, @@ -118,6 +140,7 @@ export function useBackupUpload( // Upload state — single-file flow, drives the file input + button. const fileInputRef = useRef(null); + const [orphan, setOrphan] = useState(null); const upload = useMutation({ mutationFn: async (file: File) => { // Ask first, so a large file is not sent once only to be refused. @@ -131,8 +154,13 @@ export function useBackupUpload( ); queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, - onError: (err) => { + onError: (err, file) => { if (err instanceof ReproofCancelledError) return; + // Not a dead end: the dialog offers an account to restore it into. + if (isOwnerMissing(err)) { + setOrphan(file); + return; + } showErrorToast( err instanceof Error && err.message ? err.message @@ -147,10 +175,43 @@ export function useBackupUpload( }, }); + const uploadInto = useMutation({ + mutationFn: async (target: BackupUploadTarget) => { + if (!orphan) throw new Error("No file to upload"); + await recentProof.run(() => apiGet("/api/auth/reproof")); + return { data: await uploadOnce(orphan, target), target }; + }, + onSuccess: ({ data, target }) => { + const total = uploadedRecordCount(data.summary); + toast.success( + t("admin.section.backups.target.success", { + count: String(total), + username: target.into === "new" ? target.username : target.confirm, + }), + ); + setOrphan(null); + queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() }); + }, + // A refusal stays in the dialog, beside the choice it refuses. + }); + function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; if (file) upload.mutate(file); } - return { upload, fileInputRef, handleFileChange }; + function closeOrphan() { + setOrphan(null); + uploadInto.reset(); + } + + return { + upload, + fileInputRef, + handleFileChange, + orphan, + uploadInto, + closeOrphan, + }; } diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 8a3e28cf4..c5157762e 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -44,6 +44,12 @@ export const adminKeys = { /** v1.37.20 — restore preview: the counts a stored backup file carries. */ adminBackupSummary: (id: string) => ["admin", "backups", id, "summary"] as const, + /** + * Whether an account can take the copy of an erased account. Under the + * backup list's prefix, so a new copy refetches it. + */ + adminBackupTarget: (userId: string) => + ["admin", "backups", "target", userId] as const, /** * v1.39.1 — the restore jobs the backups console shows. Its own prefix, so * invalidating the backup list does not refetch the poll and vice versa. From b1bbffc69b0415361eda6a27ef8690500bd674fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:20:55 +0100 Subject: [PATCH 09/25] docs(backup): restoring the copy of an erased account The runbook said a restore writes into an account that exists under the same id, which left an operator with an erased account's file and no way in. It now says where such a copy can go, how the new account is signed into, why an account with records is refused, and what does not come back. --- docs/ops/backup-restore.md | 48 ++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2b57bb9cb..2ab5215f3 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -2,9 +2,10 @@ HealthLog keeps copies of each account so that one account can be rolled back on this server. These copies are per account. They cannot rebuild a lost -server: a restore writes into an account that already exists here under the -same id, and refuses a copy whose account is gone. To recover a server, you -need a database dump and the encryption keys. That procedure is in +server: a restore writes into an account on this server, the one the copy +belongs to, or, when that account has been erased, one you choose (see +"Restoring an erased account"). To recover a server, you need a database dump +and the encryption keys. That procedure is in [disaster-recovery.md](disaster-recovery.md). ## What exists @@ -81,13 +82,50 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-.json.enc /tmp/restor ``` Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after -compression) and restore it from its row. The account `` must exist on this -server. +compression) and restore it from its row. When the account `` no longer +exists on this server, the upload offers to put the copy into another account +(next section). Every restore, preview and upload checks first that this server holds every application key the copy's content was written under, and refuses with the key id named if not. Nothing is changed by a refused restore. +### Restoring an erased account + +Once an account has been erased (its deletion window ended and the hourly purge +ran), its copies on this server and in the bucket are gone, but a file you kept +still holds the record: a disaster-recovery download, an off-host object you +fetched before it expired, or the person's own full backup. Uploading it in +Admin → Backups is refused at first, because the account it names no longer +exists, and the console then asks where the records should go: + +- **A new account.** Type a username. The account is created without a + password or email, as an ordinary account with the user role, and nobody + can sign into it yet. Before handing it over, set its first password with + **Reset password** in Admin → Users; the person can change it, add an email + and enrol a passkey from their settings. +- **An existing account**, for someone who has signed up again. Pick the + account and type its username to confirm. The account must hold no records + yet: a restore replaces an account's records and does not merge them, so an + account with even one record of any kind a copy carries is refused, with the + kinds named. Managed profiles and accounts pending deletion are not offered. + +The file is then uploaded a second time, and its owner is changed to the chosen +account as it is read. Nothing else in the file changes. The stored copy is +then that account's copy in every respect: its preview, its download and its +restore are the ordinary ones, and **Restore** on its row brings everything +back under the chosen account. The erased account's id is not stored, not in +the copy and not in the audit trail. + +A copy whose own account still exists on this server can only go back into +that account; choosing a target for it is refused. The copy does not carry +what a restore never touches (below), so a returning person signs in fresh, +and the links of a managed profile to its guardians do not come back: a +restored managed profile is an ordinary account. + +Once stored, the copy restores like any other: a restore run later, after the +account has gained records, replaces those too. + ### What a restore does not touch API tokens, trusted devices, step-up elevations, known devices, clinician share From 42b2c72e28db248c83edf13e8855cb15ef4e643e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 19:54:02 +0100 Subject: [PATCH 10/25] feat(timeline): serve lab markers as value lines The timeline showed a lab report as an event on the axis, but the lines under the lanes could only be measurements. A lab marker can now be named in `values` as `lab:`, beside the measurement types and `MOOD`, so one choice, one request and one stored list carry every line. `GET /api/timeline` answers with `labSeries`, one entry per named marker with its readings in the window as single points (lab values come a few a year, so they are never averaged into buckets), each dated by the record's local day, judged against its own window and linked to the report it was read from when the caller may see documents. `availableLabs` lists every marker the record has a numeric reading for, so a client offers only those. A reading stored in another unit than its marker's is converted through the unit-conversion registry where the pair is known, and otherwise left out and counted in `excluded`, never drawn on a scale it is not in. A marker id that is not in the record yields no line instead of a 422, so a stored choice survives a deleted marker; ids are capped at 24 a request. Refs #1299 --- docs/api/openapi.yaml | 177 ++++++++- e2e/utils/mock-timeline.ts | 4 + src/app/api/timeline/route.ts | 1 + .../timeline/__tests__/lab-day-chip.test.tsx | 2 + .../__tests__/no-raw-code-guard.test.tsx | 2 + .../timeline/__tests__/timeline-fixture.ts | 2 + .../__tests__/timeline-rework.test.tsx | 2 + src/lib/day/contract.ts | 28 ++ src/lib/day/wire-schemas.ts | 96 ++++- src/lib/timeline/__tests__/lab-series.test.ts | 283 +++++++++++++ src/lib/timeline/lab-series.ts | 316 +++++++++++++++ src/lib/timeline/load-timeline.ts | 87 ++-- tests/integration/timeline-lab-series.test.ts | 372 ++++++++++++++++++ 13 files changed, 1336 insertions(+), 36 deletions(-) create mode 100644 src/lib/timeline/__tests__/lab-series.test.ts create mode 100644 src/lib/timeline/lab-series.ts create mode 100644 tests/integration/timeline-lab-series.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f31a99c8e..b4f2a97c7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26131,11 +26131,17 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike + names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is + left out of `labSeries` without an error, so a stored choice never breaks the page. type: string - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike + names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left + out of `labSeries` without an error, so a stored choice never breaks the page. responses: "200": description: The timeline for the requested zoom and window. @@ -92030,6 +92036,18 @@ components: description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`." + labSeries: + type: array + items: + $ref: "#/components/schemas/TimelineLabSeries" + description: The lab markers named in `values`, in the order named, each with its single readings in the window. A + marker named but not found in the record is left out. + availableLabs: + type: array + items: + $ref: "#/components/schemas/TimelineLabMarker" + description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. + Each may be named in `values` by its `key`. notable: type: array items: @@ -92057,6 +92075,8 @@ components: - bucket - series - availableSeries + - labSeries + - availableLabs - notable additionalProperties: false TimelineItem: @@ -92209,6 +92229,155 @@ components: - unit - points additionalProperties: false + TimelineLabSeries: + type: object + properties: + key: + type: string + description: "`lab:`, as named in `values`." + biomarkerId: + type: string + name: + type: string + description: The marker's name, as the labs list shows it. + panel: + type: + - string + - "null" + unit: + type: string + description: The marker's own unit; every point is in it. + band: + anyOf: + - type: object + properties: + low: + type: + - number + - "null" + high: + type: + - number + - "null" + required: + - low + - high + additionalProperties: false + - type: "null" + description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest + reading in the window. Null when neither is known." + points: + type: array + items: + $ref: "#/components/schemas/TimelineLabPoint" + description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A + day with two readings has two points." + excluded: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` + rather than drawn on a scale they are not in. + required: + - key + - biomarkerId + - name + - panel + - unit + - band + - points + - excluded + additionalProperties: false + TimelineLabPoint: + type: object + properties: + id: + type: string + description: The lab result's id. + date: + type: string + description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. + value: + type: number + description: In the series' `unit`. + sourceValue: + description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision + `value` is shown with. Null for a reading stored as stated. + type: + - number + - "null" + rangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. + referenceLow: + description: "The window this reading is judged against, in the series' unit: the one its report printed, else the + marker's own." + type: + - number + - "null" + referenceHigh: + type: + - number + - "null" + report: + anyOf: + - type: object + properties: + id: + type: string + title: + description: The document's title, else its file name. + type: + - string + - "null" + href: + type: string + required: + - id + - title + - href + additionalProperties: false + - type: "null" + description: The document the reading was read from, when one is linked and the caller may see documents. + required: + - id + - date + - value + - sourceValue + - rangeStatus + - referenceLow + - referenceHigh + - report + additionalProperties: false + TimelineLabMarker: + type: object + properties: + key: + type: string + description: "`lab:`, to name in `values`." + biomarkerId: + type: string + name: + type: string + panel: + description: The marker's group, as the labs list shows it. + type: + - string + - "null" + unit: + type: string + required: + - key + - biomarkerId + - name + - panel + - unit + additionalProperties: false TimelineReadinessEnvelope: type: object properties: diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 512b90c8f..0d6e4f284 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -512,6 +512,8 @@ export function fullTimeline(today: string) { bucket: "quarter" as Bucket, series: series("2019-01-01", today, "quarter"), availableSeries: FULL_AVAILABLE, + labSeries: [], + availableLabs: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } @@ -637,6 +639,8 @@ export function readyTimeline(today: string) { (s) => s.key !== "RESTING_HEART_RATE", ), availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], + labSeries: [], + availableLabs: [], notable: [], }; } diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 6180427b2..8adf3ee06 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -69,6 +69,7 @@ export const GET = apiHandler(async (request: Request) => { lanes: timeline.lanes.length, items: timeline.lanes.reduce((n, l) => n + l.items.length, 0), series: timeline.series.length, + labSeries: timeline.labSeries.length, notable: timeline.notable.length, }, }); diff --git a/src/components/timeline/__tests__/lab-day-chip.test.tsx b/src/components/timeline/__tests__/lab-day-chip.test.tsx index e6d5b6c4f..b145566ef 100644 --- a/src/components/timeline/__tests__/lab-day-chip.test.tsx +++ b/src/components/timeline/__tests__/lab-day-chip.test.tsx @@ -34,6 +34,8 @@ function timeline(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], } as unknown as TimelineResponse; } diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 0859365b1..8934caa50 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -258,6 +258,8 @@ function everyKind(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], }; // The fixture itself must reach every kind, or the guard proves less. diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 79bf546b1..70762d50f 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -284,6 +284,8 @@ export function fullTimeline(): TimelineResponse { }, ], availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], + labSeries: [], + availableLabs: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 45bac0cae..80ac9f356 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -150,6 +150,8 @@ function twoMedications(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], }; } diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 387b6f64f..97651415e 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -425,6 +425,34 @@ export type TimelineResponse = z.infer; export type TimelineLane = TimelineResponse["lanes"][number]; export type TimelineItem = TimelineLane["items"][number]; export type TimelineSeries = TimelineResponse["series"][number]; +export type TimelineLabSeries = TimelineResponse["labSeries"][number]; +export type TimelineLabPoint = TimelineLabSeries["points"][number]; +export type TimelineLabMarker = TimelineResponse["availableLabs"][number]; + +/** + * A lab marker's line is named in `values` as `lab:` (#1299), + * beside the measurement types and `MOOD`, so one choice, one request and + * one stored list carry every line under the lanes. + */ +export const TIMELINE_LAB_KEY_PREFIX = "lab:"; + +/** + * The most lab markers one request names. Measurement names are a closed + * set and bound themselves; marker ids are not. + */ +export const TIMELINE_LAB_SERIES_MAX = 24; + +/** The marker id a `values` entry names, or null when it names no marker. */ +export function timelineLabMarkerId(key: string): string | null { + if (!key.startsWith(TIMELINE_LAB_KEY_PREFIX)) return null; + const id = key.slice(TIMELINE_LAB_KEY_PREFIX.length); + return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null; +} + +/** The `values` entry for a lab marker. */ +export function timelineLabKey(biomarkerId: string): string { + return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`; +} /** `GET /api/timeline/readiness` → `data`. */ export type TimelineReadinessResponse = z.infer< diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 637327877..4a5c892c3 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -370,7 +370,7 @@ export const timelineQuerySchema = z .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.", + "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left out of `labSeries` without an error, so a stored choice never breaks the page.", ), }) .refine((q) => q.zoom !== "range" || (q.from && q.to), { @@ -466,6 +466,90 @@ const timelineSeries = z }) .meta({ id: "TimelineSeries" }); +const labRangeStatus = z + .enum(["in-range", "below", "above", "unknown"]) + .describe( + "The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.", + ); + +const timelineLabPoint = z + .object({ + id: z.string().describe("The lab result's id."), + date: dateKeySchema.describe( + "The record's local day of the draw, the day `GET /api/day/{date}` lists it under.", + ), + value: z.number().describe("In the series' `unit`."), + sourceValue: z + .number() + .nullable() + .describe( + "The figure the report printed, when `value` was converted into the series' unit; it sets the precision `value` is shown with. Null for a reading stored as stated.", + ), + rangeStatus: labRangeStatus, + referenceLow: z + .number() + .nullable() + .describe( + "The window this reading is judged against, in the series' unit: the one its report printed, else the marker's own.", + ), + referenceHigh: z.number().nullable(), + report: z + .object({ + id: z.string(), + title: z + .string() + .nullable() + .describe("The document's title, else its file name."), + href: z.string(), + }) + .nullable() + .describe( + "The document the reading was read from, when one is linked and the caller may see documents.", + ), + }) + .meta({ id: "TimelineLabPoint" }); + +const timelineLabSeries = z + .object({ + key: z.string().describe("`lab:`, as named in `values`."), + biomarkerId: z.string(), + name: z.string().describe("The marker's name, as the labs list shows it."), + panel: z.string().nullable(), + unit: z.string().describe("The marker's own unit; every point is in it."), + band: z + .object({ low: z.number().nullable(), high: z.number().nullable() }) + .nullable() + .describe( + "The reference range to draw behind the points: the marker's own, else the window printed with its newest reading in the window. Null when neither is known.", + ), + points: z + .array(timelineLabPoint) + .describe( + "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A day with two readings has two points.", + ), + excluded: z + .number() + .int() + .min(0) + .describe( + "Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` rather than drawn on a scale they are not in.", + ), + }) + .meta({ id: "TimelineLabSeries" }); + +const timelineLabMarker = z + .object({ + key: z.string().describe("`lab:`, to name in `values`."), + biomarkerId: z.string(), + name: z.string(), + panel: z + .string() + .nullable() + .describe("The marker's group, as the labs list shows it."), + unit: z.string(), + }) + .meta({ id: "TimelineLabMarker" }); + export const timelineResponseSchema = z .object({ zoom: z.enum(TIMELINE_ZOOMS), @@ -496,6 +580,16 @@ export const timelineResponseSchema = z .describe( "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.", ), + labSeries: z + .array(timelineLabSeries) + .describe( + "The lab markers named in `values`, in the order named, each with its single readings in the window. A marker named but not found in the record is left out.", + ), + availableLabs: z + .array(timelineLabMarker) + .describe( + "Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.", + ), notable: z.array( z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }), ), diff --git a/src/lib/timeline/__tests__/lab-series.test.ts b/src/lib/timeline/__tests__/lab-series.test.ts new file mode 100644 index 000000000..1a29fdc5f --- /dev/null +++ b/src/lib/timeline/__tests__/lab-series.test.ts @@ -0,0 +1,283 @@ +/** + * Lab markers as lines on the timeline (#1299): the shape of one marker's + * line, and the `values` entries that name one. + * + * A lab line is its readings, one point each, never a mean; a reading in a + * unit that converts is drawn converted, one that does not is counted and + * left out; each point is judged against its own window and the band behind + * the line is the marker's own range. All data invented. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { + TIMELINE_LAB_SERIES_MAX, + timelineLabMarkerId, +} from "@/lib/day/contract"; + +import { parseSeriesKeys } from "../load-timeline"; +import { + shapeLabSeries, + type LabLineMarker, + type LabLineRow, +} from "../lab-series"; + +const FERRITIN: LabLineMarker = { + id: "bm1", + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: "Iron", + analyteKey: "ferritin", +}; + +const GLUCOSE: LabLineMarker = { + id: "bm2", + name: "Fasting glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 99, + panel: null, + analyteKey: "fasting-glucose", +}; + +function row(over: Partial & Pick): LabLineRow { + return { + analyte: "Ferritin", + panel: null, + value: 100, + unit: "ng/mL", + sourceValue: null, + sourceUnit: null, + referenceLow: null, + referenceHigh: null, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + biomarkerId: "bm1", + takenAt: new Date("2025-03-10T07:30:00.000Z"), + report: null, + ...over, + }; +} + +const WINDOW = { from: "2025-01-01", to: "2025-12-31", tz: "Europe/Berlin" }; + +describe("a lab marker's line", () => { + it("is its readings, one point each, oldest first, never a mean", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "r3", value: 52, takenAt: new Date("2025-11-02T08:00:00Z") }), + row({ id: "r1", value: 41, takenAt: new Date("2025-02-03T08:00:00Z") }), + // Two draws on one day stay two points. + row({ + id: "r2a", + value: 44, + takenAt: new Date("2025-02-20T07:00:00Z"), + }), + row({ + id: "r2b", + value: 46, + takenAt: new Date("2025-02-20T15:00:00Z"), + }), + ], + WINDOW, + ); + expect(line.key).toBe("lab:bm1"); + expect(line.unit).toBe("ng/mL"); + expect(line.points.map((p) => [p.id, p.date, p.value])).toEqual([ + ["r1", "2025-02-03", 41], + ["r2a", "2025-02-20", 44], + ["r2b", "2025-02-20", 46], + ["r3", "2025-11-02", 52], + ]); + expect(line.excluded).toBe(0); + }); + + it("dates a reading by the record's local day, as the day view lists it", () => { + // 23:30 UTC on 31 Dec is already 1 Jan in Berlin. + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "late", takenAt: new Date("2024-12-31T23:30:00Z") }), + row({ id: "out", takenAt: new Date("2025-12-31T23:30:00Z") }), + ], + WINDOW, + ); + expect(line.points.map((p) => [p.id, p.date])).toEqual([ + ["late", "2025-01-01"], + ]); + }); + + it("judges each reading against its own printed window, else the marker's", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "low", value: 12 }), + row({ + id: "printed", + value: 420, + takenAt: new Date("2025-04-01T08:00:00Z"), + sourceReferenceLow: 20, + sourceReferenceHigh: 500, + }), + row({ + id: "high", + value: 450, + takenAt: new Date("2025-05-01T08:00:00Z"), + }), + ], + WINDOW, + ); + expect( + line.points.map((p) => [ + p.id, + p.rangeStatus, + p.referenceLow, + p.referenceHigh, + ]), + ).toEqual([ + ["low", "below", 30, 400], + // In range against the window its own report printed. + ["printed", "in-range", 20, 500], + ["high", "above", 30, 400], + ]); + // The band behind the line is the marker's own range. + expect(line.band).toEqual({ low: 30, high: 400 }); + }); + + it("falls back to the newest reading's window when the marker has no range", () => { + const line = shapeLabSeries( + { ...FERRITIN, lowerBound: null, upperBound: null }, + [ + row({ id: "a", sourceReferenceLow: 10, sourceReferenceHigh: 300 }), + row({ + id: "b", + takenAt: new Date("2025-06-01T08:00:00Z"), + sourceReferenceLow: 15, + sourceReferenceHigh: 150, + }), + ], + WINDOW, + ); + expect(line.band).toEqual({ low: 15, high: 150 }); + const bare = shapeLabSeries( + { ...FERRITIN, lowerBound: null, upperBound: null }, + [row({ id: "a" })], + WINDOW, + ); + expect(bare.band).toBeNull(); + expect(bare.points[0].rangeStatus).toBe("unknown"); + }); + + it("converts a reading in another unit that converts, window and all", () => { + const line = shapeLabSeries( + GLUCOSE, + [ + row({ + id: "mmol", + analyte: "Fasting glucose", + biomarkerId: "bm2", + value: 5.5, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.5, + }), + // The same unit spelled another way is the same unit. + row({ + id: "spelled", + analyte: "Fasting glucose", + biomarkerId: "bm2", + value: 92, + unit: "mg/dl", + takenAt: new Date("2025-06-01T08:00:00Z"), + }), + ], + WINDOW, + ); + const [mmol, spelled] = line.points; + expect(mmol.value).toBeCloseTo(99.09, 1); + // The figure as stored sets the precision the converted one shows with. + expect(mmol.sourceValue).toBe(5.5); + expect(mmol.referenceLow).toBeCloseTo(70.26, 1); + expect(mmol.referenceHigh).toBeCloseTo(99.09, 1); + expect(mmol.rangeStatus).toBe("in-range"); + expect(spelled).toMatchObject({ value: 92, sourceValue: null }); + expect(line.excluded).toBe(0); + }); + + it("leaves out and counts a reading whose unit does not convert", () => { + // Ferritin has no molar conversion: pmol/L is never drawn on a ng/mL scale. + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "ok", value: 80 }), + row({ + id: "molar", + value: 180, + unit: "pmol/L", + takenAt: new Date("2025-06-01T08:00:00Z"), + }), + ], + WINDOW, + ); + expect(line.points.map((p) => p.id)).toEqual(["ok"]); + expect(line.excluded).toBe(1); + }); + + it("links the report a reading was read from", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "doc", report: { id: "d1", title: "Lab report March" } }), + row({ id: "none", takenAt: new Date("2025-04-01T08:00:00Z") }), + ], + WINDOW, + ); + expect(line.points[0].report).toEqual({ + id: "d1", + title: "Lab report March", + href: "/documents?doc=d1", + }); + expect(line.points[1].report).toBeNull(); + }); + + it("has no points, and says so, when the window holds no reading", () => { + const line = shapeLabSeries(FERRITIN, [], WINDOW); + expect(line.points).toEqual([]); + expect(line.excluded).toBe(0); + expect(line.band).toEqual({ low: 30, high: 400 }); + }); +}); + +describe("a lab marker in `values`", () => { + it("is named `lab:`, nothing else", () => { + expect(timelineLabMarkerId("lab:ckx1abc")).toBe("ckx1abc"); + expect(timelineLabMarkerId("WEIGHT")).toBeNull(); + expect(timelineLabMarkerId("lab:")).toBeNull(); + expect(timelineLabMarkerId("lab:a,b")).toBeNull(); + expect(timelineLabMarkerId("lab:../x")).toBeNull(); + }); + + it("rides beside measurement names, and an unknown name still refuses", () => { + expect(parseSeriesKeys("WEIGHT,lab:bm1,lab:bm1,MOOD")).toEqual([ + "WEIGHT", + "lab:bm1", + "MOOD", + ]); + expect(parseSeriesKeys("lab:bm1,NOPE")).toBeNull(); + expect(parseSeriesKeys("lab:../etc")).toBeNull(); + }); + + it("is capped, because marker ids are not a closed set", () => { + const many = (n: number) => + Array.from({ length: n }, (_, i) => `lab:m${i}`).join(","); + expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX))).toHaveLength( + TIMELINE_LAB_SERIES_MAX, + ); + expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX + 1))).toBeNull(); + }); +}); diff --git a/src/lib/timeline/lab-series.ts b/src/lib/timeline/lab-series.ts new file mode 100644 index 000000000..d48838a13 --- /dev/null +++ b/src/lib/timeline/lab-series.ts @@ -0,0 +1,316 @@ +/** + * Lab markers as lines on the timeline (#1299). + * + * A measurement line is a mean per bucket; a lab marker is a handful of + * readings a year, and a mean of them would invent a value nobody measured. + * So a lab line is its readings, one point each, on the day of the draw. The + * day is the record's local day of `takenAt`, the same cut the labs lane and + * the day view make, so a point opens the day that lists it. + * + * Only markers linked to a `Biomarker` are offered: every write links one, + * the backfill links the rest, and the labs list itself shows an unlinked + * group as not yet linked. A marker is read through `resolveLabFields`, the + * resolver every lab surface shares, so its name, unit, panel and the window + * each reading is judged against are the labs page's own. + * + * Units. A reading stored since v1.40 is in its marker's unit by + * construction. Older ones were linked by the backfill under the unit of the + * group's newest reading, and a marker's unit can be edited after its + * readings were written, so a row's own `unit` can still differ from the + * marker's. Such a row is converted through the unit-conversion registry + * when the pair is known (its printed window with it) and otherwise left out + * and counted in `excluded`: it is never drawn on a scale it is not in. + */ +import { + classifyAgainstEffectiveRange, + resolveEffectiveReferenceRange, +} from "@/lib/labs/reference-range"; +import { resolveLabFields, type LabFieldRow } from "@/lib/labs/serialise"; +import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { + convertLabRange, + convertLabValue, +} from "@/lib/labs/unit-conversion/convert"; +import { + timelineLabKey, + type TimelineLabMarker, + type TimelineLabPoint, + type TimelineLabSeries, +} from "@/lib/day/contract"; +import { prisma } from "@/lib/db"; +import { listTargetsBySource } from "@/lib/links/link-service"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +import { laneVisible, type TimelineAccess } from "./lanes"; + +/** The marker columns a lab line needs. */ +export interface LabLineMarker { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + analyteKey: string | null; +} + +/** One numeric reading of a marker, with the report it came from. */ +export interface LabLineRow extends LabFieldRow { + id: string; + value: number; + sourceValue: number | null; + takenAt: Date; + report: { id: string; title: string | null } | null; +} + +/** A reading in the marker's unit, or null when its unit does not convert. */ +function inMarkerUnit( + row: LabLineRow, + marker: LabLineMarker, +): Omit | null { + if (sameLabUnit(row.unit, marker.unit)) { + const fields = resolveLabFields(row, marker); + return { + value: row.value, + sourceValue: row.sourceValue, + rangeStatus: classifyAgainstEffectiveRange( + row.value, + fields.effectiveRange, + ), + referenceLow: fields.referenceLow, + referenceHigh: fields.referenceHigh, + }; + } + const converted = convertLabValue({ + analyteKey: marker.analyteKey, + value: row.value, + from: row.unit, + to: marker.unit, + }); + if (!converted.ok) return null; + // The printed window is in the row's unit; it moves with the value or is + // dropped (the marker's own band then judges the reading). + const printed = convertLabRange({ + analyteKey: marker.analyteKey, + low: row.sourceReferenceLow, + high: row.sourceReferenceHigh, + from: row.unit, + to: marker.unit, + }); + const effective = resolveEffectiveReferenceRange( + marker.lowerBound, + marker.upperBound, + { + sourceReferenceLow: printed.ok ? printed.low : null, + sourceReferenceHigh: printed.ok ? printed.high : null, + sourceReferenceText: null, + sourceUnit: row.unit, + }, + ); + return { + value: converted.value, + // The figure as stored sets the precision the converted one is shown with. + sourceValue: row.value, + rangeStatus: classifyAgainstEffectiveRange(converted.value, effective), + referenceLow: effective.low, + referenceHigh: effective.high, + }; +} + +/** + * One marker's line over `[from, to]` from its numeric readings (any order): + * a point per reading inside the window, oldest first, the readings whose + * unit does not convert counted, and the band to draw behind them. + */ +export function shapeLabSeries( + marker: LabLineMarker, + rows: readonly LabLineRow[], + window: { from: string; to: string; tz: string }, +): TimelineLabSeries { + const drawn: Array<{ point: TimelineLabPoint; at: number }> = []; + let excluded = 0; + for (const row of rows) { + const date = userDayKey(row.takenAt, window.tz); + if (date < window.from || date > window.to) continue; + const reading = inMarkerUnit(row, marker); + if (!reading) { + excluded += 1; + continue; + } + drawn.push({ + point: { + id: row.id, + date, + ...reading, + report: row.report + ? { + id: row.report.id, + title: row.report.title, + href: `/documents?doc=${row.report.id}`, + } + : null, + }, + at: row.takenAt.getTime(), + }); + } + const points = drawn + .sort((a, b) => a.at - b.at || (a.point.id < b.point.id ? -1 : 1)) + .map((d) => d.point); + const newest = points.at(-1); + const band = + marker.lowerBound !== null || marker.upperBound !== null + ? { low: marker.lowerBound, high: marker.upperBound } + : newest && + (newest.referenceLow !== null || newest.referenceHigh !== null) + ? { low: newest.referenceLow, high: newest.referenceHigh } + : null; + return { + key: timelineLabKey(marker.id), + biomarkerId: marker.id, + name: marker.name, + panel: marker.panel, + unit: marker.unit, + band, + points, + excluded, + }; +} + +const MARKER_SELECT = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + analyteKey: true, +} as const; + +/** + * The lines of the markers `ids` names, in that order, over `[from, to]`. + * Every read is narrowed to the record: an id of another record's marker + * finds nothing and is left out, as is a marker deleted since it was chosen. + * The report behind a reading is read only when the caller may see + * documents. + */ +export async function loadLabSeries(args: { + recordId: string; + ids: readonly string[]; + from: string; + to: string; + tz: string; + access: TimelineAccess; +}): Promise { + const { recordId, ids, from, to, tz, access } = args; + if (ids.length === 0 || !laneVisible("labs", access)) return []; + const markers = await prisma.biomarker.findMany({ + where: { userId: recordId, id: { in: [...ids] } }, + select: MARKER_SELECT, + }); + if (markers.length === 0) return []; + const rows = await prisma.labResult.findMany({ + where: { + userId: recordId, + biomarkerId: { in: markers.map((m) => m.id) }, + deletedAt: null, + value: { not: null }, + takenAt: { + gte: startOfLocalDayKey(from, tz), + lt: startOfLocalDayKey(shiftDateKey(to, 1), tz), + }, + }, + select: { + id: true, + biomarkerId: true, + analyte: true, + panel: true, + value: true, + unit: true, + sourceValue: true, + sourceUnit: true, + referenceLow: true, + referenceHigh: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceReferenceText: true, + takenAt: true, + }, + orderBy: [{ takenAt: "asc" }, { id: "asc" }], + }); + // A reading read from two documents names the first one linked. The link + // service drops a deleted document and narrows every read to the record. + const reportOf = new Map(); + if (rows.length > 0 && laneVisible("documents", access)) { + const linked = await listTargetsBySource(prisma, { + userId: recordId, + sourceKind: "labResult", + sourceIds: rows.map((r) => r.id), + targetKind: "document", + }); + for (const [labResultId, [first]] of linked) { + if (!first) continue; + // A document with neither title nor file name is labelled by its id. + reportOf.set(labResultId, { + id: first.id, + title: first.label === first.id ? null : first.label, + }); + } + } + const byMarker = new Map(); + for (const row of rows) { + if (row.biomarkerId === null || row.value === null) continue; + const list = byMarker.get(row.biomarkerId) ?? []; + list.push({ + ...row, + value: row.value, + report: reportOf.get(row.id) ?? null, + }); + byMarker.set(row.biomarkerId, list); + } + const found = new Map(markers.map((m) => [m.id, m])); + return ids.flatMap((id) => { + const marker = found.get(id); + return marker + ? [shapeLabSeries(marker, byMarker.get(id) ?? [], { from, to, tz })] + : []; + }); +} + +/** + * Every marker the record has a numeric reading for, whatever the window, + * by name. A marker with only qualitative readings ("negative") has nothing + * to draw and is not offered. + */ +export async function loadAvailableLabMarkers(args: { + recordId: string; + access: TimelineAccess; +}): Promise { + const { recordId, access } = args; + if (!laneVisible("labs", access)) return []; + const groups = await prisma.labResult.groupBy({ + by: ["biomarkerId"], + where: { + userId: recordId, + deletedAt: null, + value: { not: null }, + biomarkerId: { not: null }, + }, + }); + const ids = groups + .map((g) => g.biomarkerId) + .filter((id): id is string => id !== null); + if (ids.length === 0) return []; + const markers = await prisma.biomarker.findMany({ + where: { userId: recordId, id: { in: ids } }, + select: { id: true, name: true, panel: true, unit: true }, + orderBy: [{ name: "asc" }, { id: "asc" }], + }); + return markers.map((m) => ({ + key: timelineLabKey(m.id), + biomarkerId: m.id, + name: m.name, + panel: m.panel, + unit: m.unit, + })); +} diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 2493257a4..4e07ee67b 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -18,7 +18,9 @@ import type { MeasurementType } from "@/generated/prisma/enums"; import { DAY_NOTABLE_MAX_SPAN_DAYS, + TIMELINE_LAB_SERIES_MAX, TIMELINE_LANE_KEYS, + timelineLabMarkerId, type TimelineBucket, type TimelineItem, type TimelineQuery, @@ -36,6 +38,10 @@ import { type StandingItem, type TimelineAccess, } from "@/lib/timeline/lanes"; +import { + loadAvailableLabMarkers, + loadLabSeries, +} from "@/lib/timeline/lab-series"; import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; @@ -107,8 +113,12 @@ const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( /** * The series keys a `values` parameter names, or null when one is not a * series this API knows (the route answers 422). There is no cap on the - * count: a name given twice counts once, so the list never grows past the - * names that exist, and each series is its own row under the lanes. + * measurement names: a name given twice counts once, so the list never + * grows past the names that exist, and each series is its own row under the + * lanes. A lab marker (`lab:`, #1299) is checked for its shape + * only: whether the record has it is the loader's question, and the answer + * to a stale one is no line rather than an error. Marker ids are not a + * closed set, so their count is capped. */ export function parseSeriesKeys(raw: string | undefined): string[] | null { if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; @@ -120,10 +130,15 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null { .filter((k) => k.length > 0), ), ]; + let labs = 0; for (const key of keys) { + if (timelineLabMarkerId(key) !== null) { + labs += 1; + continue; + } if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; } - return keys; + return labs > TIMELINE_LAB_SERIES_MAX ? null : keys; } /** @@ -246,11 +261,16 @@ export async function loadTimeline(args: { standing.push(...reads[i].standing); }); + const labIds = args.seriesKeys + .map(timelineLabMarkerId) + .filter((id): id is string => id !== null); const seriesKeys = args.seriesKeys.filter((key) => - key === MOOD_SERIES_KEY - ? access.modules.mood !== false && access.domainVisible("mind") - : measurementsVisible && - measurementTypeVisible(key as MeasurementType, access.modules), + timelineLabMarkerId(key) !== null + ? false + : key === MOOD_SERIES_KEY + ? access.modules.mood !== false && access.domainVisible("mind") + : measurementsVisible && + measurementTypeVisible(key as MeasurementType, access.modules), ); const priorityJson = measurementsVisible ? await loadUserSourcePriority(recordId) @@ -260,30 +280,33 @@ export async function loadTimeline(args: { ? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) : from; const bucket = timelineBucket(query.zoom, from, to); - const [series, notable, availableSeries] = await Promise.all([ - loadTimelineSeries({ - userId: recordId, - keys: seriesKeys, - from, - to, - tz, - bucket, - priorityJson, - now, - }), - measurementsVisible - ? loadNotableRange({ - userId: recordId, - from: notableFrom, - to, - tz, - priorityJson, - typeVisible: (t) => measurementTypeVisible(t, access.modules), - gaps: false, - }) - : Promise.resolve([]), - loadAvailableSeries({ recordId, access }), - ]); + const [series, notable, availableSeries, labSeries, availableLabs] = + await Promise.all([ + loadTimelineSeries({ + userId: recordId, + keys: seriesKeys, + from, + to, + tz, + bucket, + priorityJson, + now, + }), + measurementsVisible + ? loadNotableRange({ + userId: recordId, + from: notableFrom, + to, + tz, + priorityJson, + typeVisible: (t) => measurementTypeVisible(t, access.modules), + gaps: false, + }) + : Promise.resolve([]), + loadAvailableSeries({ recordId, access }), + loadLabSeries({ recordId, ids: labIds, from, to, tz, access }), + loadAvailableLabMarkers({ recordId, access }), + ]); return { zoom: query.zoom, @@ -293,6 +316,8 @@ export async function loadTimeline(args: { bucket, series, availableSeries, + labSeries, + availableLabs, notable: notable.map(({ date, kind }) => ({ date, kind })), }; } diff --git a/tests/integration/timeline-lab-series.test.ts b/tests/integration/timeline-lab-series.test.ts new file mode 100644 index 000000000..9b6f54278 --- /dev/null +++ b/tests/integration/timeline-lab-series.test.ts @@ -0,0 +1,372 @@ +/** + * Lab markers as lines on the timeline (#1299), driven through + * `GET /api/timeline` against real Postgres. + * + * What the unit tests cannot show: that the record's own markers are offered + * and nobody else's, that a marker id from another record finds nothing, + * that a reading is dated by the record's local day and linked to its + * report, that a switched-off labs or documents module reads neither, that a + * grant scoped to some sections is refused, and that the route admits a cookie session and + * refuses a narrow Bearer token (the fail-closed default: the timeline names + * no scope). All data invented. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { hashToken } from "@/lib/auth/hmac"; +import type { TimelineResponse } from "@/lib/day/contract"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, 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), +})); + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +let counter = 0; + +const DAY_MS = 86_400_000; + +/** + * The calendar day `n` days before today, so every reading stays inside + * the windows below whenever the suite runs. + */ +function daysAgo(n: number): string { + return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10); +} + +/** The window every reading lies in. */ +const WINDOW = () => `zoom=range&from=${daysAgo(1000)}&to=${daysAgo(1)}`; + +/** The reading days the record's local zone puts them on. */ +const DAYS = { + low: () => daysAgo(900), + lateNight: () => shiftDateKey(daysAgo(500), 1), + recent: () => daysAgo(200), +}; + +const MODULES = { timeline: true, labs: true, inboundDocuments: true }; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `tl-lab-${suffix}`, + email: `tl-lab-${suffix}@example.test`, + displayName: `TL ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + modulePreferencesJson: MODULES, + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function mintToken(userId: string, permissions: string[]) { + const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId, + name: "timeline-lab-test", + tokenHash: hashToken(raw), + permissions, + }, + }); + return raw; +} + +async function shareWith( + ownerId: string, + delegateId: string, + scope: Array<"labs" | "measurements" | "documents"> | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegateId }); + const session = await signIn(delegateId); + await switchSessionTo(session.id, ownerId); +} + +async function getTimeline(query: string): Promise { + const { GET } = await import("@/app/api/timeline/route"); + return (GET as Handler)( + new NextRequest(`http://localhost/api/timeline?${query}`, { + method: "GET", + }), + { params: Promise.resolve({}) }, + ); +} + +async function data(response: Response): Promise { + expect(response.status).toBe(200); + return ((await response.json()) as { data: TimelineResponse }).data; +} + +/** A record with ferritin over three years, one reading linked to its report. */ +async function seedFerritin(userId: string) { + const db = getPrismaClient(); + const marker = await db.biomarker.create({ + data: { + userId, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: "Iron", + analyteKey: "ferritin", + }, + }); + const at = (iso: string, value: number, unit = "ng/mL") => + db.labResult.create({ + data: { + userId, + biomarkerId: marker.id, + analyte: "Ferritin", + value, + unit, + takenAt: new Date(iso), + }, + }); + const first = await at(`${daysAgo(900)}T12:00:00.000Z`, 18); + // 23:30 UTC is already the next day in Berlin, summer or winter. + await at(`${daysAgo(500)}T23:30:00.000Z`, 85); + await at(`${daysAgo(200)}T12:00:00.000Z`, 120); + // A reading stored in a unit that does not convert into ng/mL. + await at(`${daysAgo(199)}T12:00:00.000Z`, 260, "pmol/L"); + // A qualitative reading has nothing to draw. + await db.labResult.create({ + data: { + userId, + biomarkerId: marker.id, + analyte: "Ferritin", + valueText: "haemolysed", + unit: "", + takenAt: new Date(`${daysAgo(198)}T12:00:00.000Z`), + }, + }); + const document = await db.inboundDocument.create({ + data: { + userId, + kind: "LAB_RESULT", + title: "Lab report February", + filename: "labs.pdf", + mimeType: "application/pdf", + byteSize: 1, + contentEncrypted: new Uint8Array([1]), + contentCodec: "binary2", + }, + }); + await db.labResultDocumentLink.create({ + data: { userId, labResultId: first.id, documentId: document.id }, + }); + // A marker with only a qualitative reading is not offered. + const urine = await db.biomarker.create({ + data: { userId, name: "Urine protein", unit: "" }, + }); + await db.labResult.create({ + data: { + userId, + biomarkerId: urine.id, + analyte: "Urine protein", + valueText: "negative", + unit: "", + takenAt: new Date(`${daysAgo(200)}T12:00:00.000Z`), + }, + }); + return { marker, document }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("lab lines on the timeline", () => { + it("offers the record's markers and draws a chosen one as its readings", async () => { + const owner = await makeUser("owner"); + const { marker, document } = await seedFerritin(owner.id); + await signIn(owner.id); + + const timeline = await data( + await getTimeline(`${WINDOW()}&values=WEIGHT,lab:${marker.id}`), + ); + expect(timeline.availableLabs).toEqual([ + { + key: `lab:${marker.id}`, + biomarkerId: marker.id, + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + }, + ]); + expect(timeline.labSeries).toHaveLength(1); + const [line] = timeline.labSeries; + expect(line).toMatchObject({ + key: `lab:${marker.id}`, + name: "Ferritin", + unit: "ng/mL", + band: { low: 30, high: 400 }, + excluded: 1, + }); + expect( + line.points.map((p) => [p.date, p.value, p.rangeStatus, p.report]), + ).toEqual([ + [ + DAYS.low(), + 18, + "below", + { + id: document.id, + title: "Lab report February", + href: `/documents?doc=${document.id}`, + }, + ], + [DAYS.lateNight(), 85, "in-range", null], + [DAYS.recent(), 120, "in-range", null], + ]); + // The measurement lines are not touched by a lab entry beside them. + expect(timeline.series).toEqual([]); + + // The window decides which readings are sent; the marker stays a line. + const quarter = await data( + await getTimeline( + `zoom=range&from=${daysAgo(530)}&to=${daysAgo(470)}&values=lab:${marker.id}`, + ), + ); + expect(quarter.labSeries[0].points.map((p) => p.date)).toEqual([ + DAYS.lateNight(), + ]); + const empty = await data( + await getTimeline( + `zoom=range&from=${daysAgo(1300)}&to=${daysAgo(1200)}&values=lab:${marker.id}`, + ), + ); + expect(empty.labSeries[0]).toMatchObject({ points: [], excluded: 0 }); + }); + + it("finds nothing under another record's marker id, and does not refuse", async () => { + const owner = await makeUser("owner"); + const other = await makeUser("other"); + const { marker: theirs } = await seedFerritin(other.id); + await signIn(owner.id); + + const timeline = await data( + await getTimeline(`zoom=all&values=lab:${theirs.id},lab:gone123`), + ); + expect(timeline.labSeries).toEqual([]); + expect(timeline.availableLabs).toEqual([]); + + // A list of ids is capped, and a malformed one is refused. + const many = Array.from({ length: 25 }, (_, i) => `lab:m${i}`).join(","); + expect((await getTimeline(`values=${many}`)).status).toBe(422); + expect((await getTimeline(`values=lab:..%2Fx`)).status).toBe(422); + }); + + it("refuses a grant scoped to some sections, and shares the lines on a whole-record grant", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + + const noLabs = await makeUser("no-labs"); + await shareWith(owner.id, noLabs.id, ["measurements"]); + // The timeline is admitted on a whole-record grant only. + expect((await getTimeline(`zoom=all&values=lab:${marker.id}`)).status).toBe( + 403, + ); + + const whole = await makeUser("whole"); + await shareWith(owner.id, whole.id, null); + const shared = await data( + await getTimeline(`${WINDOW()}&values=lab:${marker.id}`), + ); + expect(shared.labSeries[0].points).toHaveLength(3); + expect(shared.availableLabs.map((m) => m.name)).toEqual(["Ferritin"]); + }); + + it("names no report with documents off, and draws no lab line with labs off", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { ...MODULES, inboundDocuments: false } }, + }); + await signIn(owner.id); + const noDocs = await data( + await getTimeline(`${WINDOW()}&values=lab:${marker.id}`), + ); + expect(noDocs.labSeries[0].points.map((p) => p.report)).toEqual([ + null, + null, + null, + ]); + + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { ...MODULES, labs: false } }, + }); + const timeline = await data( + await getTimeline(`zoom=all&values=lab:${marker.id}`), + ); + expect(timeline.labSeries).toEqual([]); + expect(timeline.availableLabs).toEqual([]); + }); + + it("admits a cookie session and a full token, refuses a narrow token and nobody", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + const query = `zoom=all&values=lab:${marker.id}`; + + expect((await getTimeline(query)).status).toBe(401); + + const narrow = await mintToken(owner.id, ["fhir:read"]); + headerJar.set("authorization", `Bearer ${narrow}`); + expect((await getTimeline(query)).status).toBe(403); + + const full = await mintToken(owner.id, ["*"]); + headerJar.set("authorization", `Bearer ${full}`); + const timeline = await data(await getTimeline(query)); + expect(timeline.labSeries[0].points.length).toBeGreaterThan(0); + }); +}); From b8a983bdec3e66e9d2b87426bfb60aacbc4c0f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:10:07 +0100 Subject: [PATCH 11/25] feat(timeline): draw lab markers as lines under the values With the server naming lab markers as value lines, the page offers them: the values menu lists the markers the record has numeric readings for, under the groups the labs list sorts them into, and a choice is stored and requested like any measurement line. The phone toolbar gains the same menu as an icon button, since the chronicle shows the chosen lines too. A lab row sits under the value lines in their rhythm, on its own scale. Each reading is its own point on the day of the draw, joined in time order by a thin stroke and never averaged. The reference range is painted behind the points as the labs page paints it, a faint band with dashed edges, and the scale holds it so the band is always in view. A reading outside its range is a triangle pointing the way it left and is worded as outside wherever it is written out, so colour is never the only sign. A point reads on its own by pointer, keyboard and tap: the day, the value with every digit it was stored with, the verdict, the range and the report it was read from (a link once pinned). The selection bar lists the bucket's readings with their report, a lab day's row in the phone chronicle lists that day's readings, a screen-reader table lists them all, and a marker with readings in a unit that does not convert says how many were left out. Nothing else on the timeline changes. To make room without growing the large timeline files, the legend, the screen-reader tables and the value-line geometry move into modules of their own, and the chart, the chronicle, the point cards and the page each hand a part to a helper; the size baselines are written back lower. Refs #1299 --- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/ko.json | 23 +- messages/nl.json | 23 +- messages/pl.json | 23 +- messages/pt-BR.json | 23 +- .../baselines/source-file-lines.json | 6 +- .../baselines/source-function-lines.json | 21 +- .../timeline/__tests__/lab-lines.test.tsx | 417 ++++++++++++++ .../__tests__/no-raw-code-guard.test.tsx | 34 +- .../__tests__/timeline-geometry.test.ts | 2 +- .../__tests__/timeline-labs.browser.test.tsx | 297 ++++++++++ .../__tests__/timeline-rework.test.tsx | 9 +- src/components/timeline/lab-lines.tsx | 540 ++++++++++++++++++ src/components/timeline/label-keys.ts | 11 + src/components/timeline/selection-bar.tsx | 23 +- src/components/timeline/series-geometry.ts | 245 ++++++++ src/components/timeline/series-point-tips.tsx | 218 +++++-- src/components/timeline/timeline-chart.tsx | 287 ++++------ .../timeline/timeline-chronicle.tsx | 100 ++-- src/components/timeline/timeline-geometry.ts | 181 ++---- src/components/timeline/timeline-legend.tsx | 193 +++++++ src/components/timeline/timeline-menus.tsx | 212 +++++-- src/components/timeline/timeline-tables.tsx | 177 ++++++ src/components/timeline/timeline-view.tsx | 232 +++----- 28 files changed, 2783 insertions(+), 629 deletions(-) create mode 100644 src/components/timeline/__tests__/lab-lines.test.tsx create mode 100644 src/components/timeline/__tests__/timeline-labs.browser.test.tsx create mode 100644 src/components/timeline/lab-lines.tsx create mode 100644 src/components/timeline/series-geometry.ts create mode 100644 src/components/timeline/timeline-legend.tsx create mode 100644 src/components/timeline/timeline-tables.tsx diff --git a/messages/de.json b/messages/de.json index d71e38a98..0b7accb9b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13655,7 +13655,11 @@ "start": "Beginn", "end": "Ende", "seriesCaption": "Mittelwerte nach Zeitraum", - "period": "Zeitraum" + "period": "Zeitraum", + "labsCaption": "Laborwerte nach Tag", + "marker": "Marker", + "date": "Datum", + "value": "Wert" }, "selection": { "empty": "Keine Einträge in diesem Zeitraum", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 Laborwert", "labValuesFew": "{count} Laborwerte", "labValuesOther": "{count} Laborwerte" - } + }, + "labs": { + "menuTitle": "Laborwerte", + "otherPanel": "Weitere", + "above": "über dem Referenzbereich", + "below": "unter dem Referenzbereich", + "reference": "Referenz {range}", + "report": "Befund: {title}", + "reportUntitled": "Befund öffnen", + "excludedOne": "{name}: {count} Wert in einer anderen Einheit ist nicht eingezeichnet", + "excludedFew": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet", + "excludedOther": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet" + }, + "legendLabValue": "Laborwert, wie gemessen", + "legendLabOutside": "Außerhalb des Referenzbereichs", + "legendLabRange": "Referenzbereich" }, "lifeEvents": { "title": "Lebensereignis", diff --git a/messages/en.json b/messages/en.json index 49c2d6fcd..234157ac0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13655,7 +13655,11 @@ "start": "Start", "end": "End", "seriesCaption": "Value means by period", - "period": "Period" + "period": "Period", + "labsCaption": "Lab values by day", + "marker": "Marker", + "date": "Date", + "value": "Value" }, "selection": { "empty": "No entries in this period", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 lab value", "labValuesFew": "{count} lab values", "labValuesOther": "{count} lab values" - } + }, + "labs": { + "menuTitle": "Lab values", + "otherPanel": "Other", + "above": "above range", + "below": "below range", + "reference": "reference {range}", + "report": "Report: {title}", + "reportUntitled": "Open the report", + "excludedOne": "{name}: {count} value in another unit is not drawn", + "excludedFew": "{name}: {count} values in another unit are not drawn", + "excludedOther": "{name}: {count} values in another unit are not drawn" + }, + "legendLabValue": "Lab value, as measured", + "legendLabOutside": "Outside the reference range", + "legendLabRange": "Reference range" }, "lifeEvents": { "title": "Life event", diff --git a/messages/es.json b/messages/es.json index 664fbb6b2..127b8be61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13655,7 +13655,11 @@ "start": "Inicio", "end": "Fin", "seriesCaption": "Medias por periodo", - "period": "Periodo" + "period": "Periodo", + "labsCaption": "Valores de laboratorio por día", + "marker": "Marcador", + "date": "Fecha", + "value": "Valor" }, "selection": { "empty": "No hay entradas en este periodo", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valor de laboratorio", "labValuesFew": "{count} valores de laboratorio", "labValuesOther": "{count} valores de laboratorio" - } + }, + "labs": { + "menuTitle": "Valores de laboratorio", + "otherPanel": "Otros", + "above": "por encima del intervalo", + "below": "por debajo del intervalo", + "reference": "referencia {range}", + "report": "Informe: {title}", + "reportUntitled": "Abrir el informe", + "excludedOne": "{name}: {count} valor en otra unidad no se muestra", + "excludedFew": "{name}: {count} valores en otra unidad no se muestran", + "excludedOther": "{name}: {count} valores en otra unidad no se muestran" + }, + "legendLabValue": "Valor de laboratorio, tal como se midió", + "legendLabOutside": "Fuera del intervalo de referencia", + "legendLabRange": "Intervalo de referencia" }, "lifeEvents": { "title": "Acontecimiento vital", diff --git a/messages/fr.json b/messages/fr.json index c48261e70..7e2595b56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13655,7 +13655,11 @@ "start": "Début", "end": "Fin", "seriesCaption": "Moyennes par période", - "period": "Période" + "period": "Période", + "labsCaption": "Valeurs de laboratoire par jour", + "marker": "Marqueur", + "date": "Date", + "value": "Valeur" }, "selection": { "empty": "Aucune entrée sur cette période", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valeur de laboratoire", "labValuesFew": "{count} valeurs de laboratoire", "labValuesOther": "{count} valeurs de laboratoire" - } + }, + "labs": { + "menuTitle": "Valeurs de laboratoire", + "otherPanel": "Autres", + "above": "au-dessus de l’intervalle", + "below": "en dessous de l’intervalle", + "reference": "référence {range}", + "report": "Compte rendu : {title}", + "reportUntitled": "Ouvrir le compte rendu", + "excludedOne": "{name} : {count} valeur dans une autre unité n’est pas affichée", + "excludedFew": "{name} : {count} valeurs dans une autre unité ne sont pas affichées", + "excludedOther": "{name} : {count} valeurs dans une autre unité ne sont pas affichées" + }, + "legendLabValue": "Valeur de laboratoire, telle que mesurée", + "legendLabOutside": "Hors de l’intervalle de référence", + "legendLabRange": "Intervalle de référence" }, "lifeEvents": { "title": "Événement de vie", diff --git a/messages/it.json b/messages/it.json index f44262fe3..a709e5531 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13655,7 +13655,11 @@ "start": "Inizio", "end": "Fine", "seriesCaption": "Medie per periodo", - "period": "Periodo" + "period": "Periodo", + "labsCaption": "Valori di laboratorio per giorno", + "marker": "Marcatore", + "date": "Data", + "value": "Valore" }, "selection": { "empty": "Nessuna voce in questo periodo", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valore di laboratorio", "labValuesFew": "{count} valori di laboratorio", "labValuesOther": "{count} valori di laboratorio" - } + }, + "labs": { + "menuTitle": "Valori di laboratorio", + "otherPanel": "Altri", + "above": "sopra l’intervallo", + "below": "sotto l’intervallo", + "reference": "riferimento {range}", + "report": "Referto: {title}", + "reportUntitled": "Apri il referto", + "excludedOne": "{name}: {count} valore in un’altra unità non è riportato", + "excludedFew": "{name}: {count} valori in un’altra unità non sono riportati", + "excludedOther": "{name}: {count} valori in un’altra unità non sono riportati" + }, + "legendLabValue": "Valore di laboratorio, così come misurato", + "legendLabOutside": "Fuori dall’intervallo di riferimento", + "legendLabRange": "Intervallo di riferimento" }, "lifeEvents": { "title": "Evento di vita", diff --git a/messages/ko.json b/messages/ko.json index 01cea67ec..50d52e248 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13655,7 +13655,11 @@ "start": "시작", "end": "끝", "seriesCaption": "기간별 평균", - "period": "기간" + "period": "기간", + "labsCaption": "날짜별 검사 수치", + "marker": "항목", + "date": "날짜", + "value": "값" }, "selection": { "empty": "이 기간에는 항목이 없습니다", @@ -13700,7 +13704,22 @@ "labValuesOne": "검사 수치 1개", "labValuesFew": "검사 수치 {count}개", "labValuesOther": "검사 수치 {count}개" - } + }, + "labs": { + "menuTitle": "검사 수치", + "otherPanel": "기타", + "above": "범위 초과", + "below": "범위 미만", + "reference": "참고 범위 {range}", + "report": "보고서: {title}", + "reportUntitled": "보고서 열기", + "excludedOne": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다", + "excludedFew": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다", + "excludedOther": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다" + }, + "legendLabValue": "측정된 그대로의 검사 수치", + "legendLabOutside": "참고 범위 밖", + "legendLabRange": "참고 범위" }, "lifeEvents": { "title": "삶의 사건", diff --git a/messages/nl.json b/messages/nl.json index 9fe4b8f08..cfd5cb096 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13655,7 +13655,11 @@ "start": "Begin", "end": "Einde", "seriesCaption": "Gemiddelde waarden per periode", - "period": "Periode" + "period": "Periode", + "labsCaption": "Labwaarden per dag", + "marker": "Marker", + "date": "Datum", + "value": "Waarde" }, "selection": { "empty": "Geen registraties in deze periode", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 labwaarde", "labValuesFew": "{count} labwaarden", "labValuesOther": "{count} labwaarden" - } + }, + "labs": { + "menuTitle": "Labwaarden", + "otherPanel": "Overig", + "above": "boven het bereik", + "below": "onder het bereik", + "reference": "referentie {range}", + "report": "Verslag: {title}", + "reportUntitled": "Verslag openen", + "excludedOne": "{name}: {count} waarde in een andere eenheid wordt niet getoond", + "excludedFew": "{name}: {count} waarden in een andere eenheid worden niet getoond", + "excludedOther": "{name}: {count} waarden in een andere eenheid worden niet getoond" + }, + "legendLabValue": "Labwaarde, zoals gemeten", + "legendLabOutside": "Buiten het referentiebereik", + "legendLabRange": "Referentiebereik" }, "lifeEvents": { "title": "Levensgebeurtenis", diff --git a/messages/pl.json b/messages/pl.json index 94fc0bcaf..22e19f30f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13655,7 +13655,11 @@ "start": "Początek", "end": "Koniec", "seriesCaption": "Średnie według okresu", - "period": "Okres" + "period": "Okres", + "labsCaption": "Wyniki badań według dni", + "marker": "Parametr", + "date": "Data", + "value": "Wartość" }, "selection": { "empty": "Brak wpisów w tym okresie", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 wynik badania", "labValuesFew": "{count} wyniki badań", "labValuesOther": "{count} wyników badań" - } + }, + "labs": { + "menuTitle": "Wyniki badań", + "otherPanel": "Inne", + "above": "powyżej zakresu", + "below": "poniżej zakresu", + "reference": "zakres {range}", + "report": "Dokument: {title}", + "reportUntitled": "Otwórz dokument", + "excludedOne": "{name}: {count} wartość w innej jednostce nie jest pokazana", + "excludedFew": "{name}: {count} wartości w innej jednostce nie są pokazane", + "excludedOther": "{name}: {count} wartości w innej jednostce nie jest pokazanych" + }, + "legendLabValue": "Wynik badania, tak jak zmierzono", + "legendLabOutside": "Poza zakresem referencyjnym", + "legendLabRange": "Zakres referencyjny" }, "lifeEvents": { "title": "Wydarzenie życiowe", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 0e3fd7d56..508fd0da6 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -13655,7 +13655,11 @@ "start": "Início", "end": "Fim", "seriesCaption": "Médias de valores por período", - "period": "Período" + "period": "Período", + "labsCaption": "Valores de exames por dia", + "marker": "Marcador", + "date": "Data", + "value": "Valor" }, "selection": { "empty": "Nenhuma entrada neste período", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valor laboratorial", "labValuesFew": "{count} valores laboratoriais", "labValuesOther": "{count} valores laboratoriais" - } + }, + "labs": { + "menuTitle": "Valores de exames", + "otherPanel": "Outros", + "above": "acima da faixa", + "below": "abaixo da faixa", + "reference": "referência {range}", + "report": "Laudo: {title}", + "reportUntitled": "Abrir o laudo", + "excludedOne": "{name}: {count} valor em outra unidade não aparece no gráfico", + "excludedFew": "{name}: {count} valores em outra unidade não aparecem no gráfico", + "excludedOther": "{name}: {count} valores em outra unidade não aparecem no gráfico" + }, + "legendLabValue": "Valor de exame, como foi medido", + "legendLabOutside": "Fora da faixa de referência", + "legendLabRange": "Faixa de referência" }, "lifeEvents": { "title": "Evento de vida", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 4f04a28da..61f72e3a8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94692, + "total": 94504, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -18,8 +18,8 @@ "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, "components/records/life-circumstances-manager.tsx": 1080, - "components/timeline/timeline-chart.tsx": 1174, - "components/timeline/timeline-geometry.ts": 1395, + "components/timeline/timeline-chart.tsx": 1093, + "components/timeline/timeline-geometry.ts": 1288, "lib/ai/anthropic-client.ts": 804, "lib/ai/coach/about-me.ts": 1012, "lib/ai/coach/coach-prose-grounding.ts": 1079, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 05a8b99d1..1cf1294f5 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,14 +1,14 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 346, - "total": 112219, + "count": 345, + "total": 111940, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286, "app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206, "app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211, "app/api/auth/login/route.ts :: apiHandler(arg)": 262, - "app/api/auth/me/route.ts :: apiHandler(arg)": 386, + "app/api/auth/me/route.ts :: apiHandler(arg)": 378, "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258, "app/api/auth/register/route.ts :: apiHandler(arg)": 268, "app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309, @@ -19,7 +19,7 @@ "app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269, "app/api/environment/route.ts :: apiHandler(arg)": 201, "app/api/export/health-record/route.ts :: apiHandler(arg)": 241, - "app/api/ingest/medication/route.ts :: apiHandler(arg)": 262, + "app/api/ingest/medication/route.ts :: apiHandler(arg)": 259, "app/api/insights/cards/route.ts :: apiHandler(arg)": 211, "app/api/integrations/status/route.ts :: apiHandler(arg)": 451, "app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239, @@ -62,7 +62,7 @@ "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, "components/admin/reminders-section.tsx :: RemindersSection": 309, - "components/admin/user-management-section.tsx :: UserManagementSection": 626, + "components/admin/user-management-section.tsx :: UserManagementSection": 622, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, @@ -232,10 +232,9 @@ "components/targets/range-bar.tsx :: RangeBar": 220, "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474, "components/timeline/life-event-form.tsx :: LifeEventForm": 281, - "components/timeline/series-point-tips.tsx :: SeriesPointTips": 212, - "components/timeline/timeline-chart.tsx :: ChartBody": 243, - "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274, - "components/timeline/timeline-view.tsx :: TimelineView": 501, + "components/timeline/timeline-chart.tsx :: ChartBody": 236, + "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, + "components/timeline/timeline-view.tsx :: TimelineView": 486, "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239, "components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221, "components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246, @@ -285,7 +284,7 @@ "lib/gamification/achievements-result.ts :: buildAchievementsResult": 586, "lib/google-health/data-points.ts :: forEachDataPointPage": 288, "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, - "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453, + "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, "lib/import/labs-csv.ts :: importLabsCsv": 309, "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, @@ -348,6 +347,6 @@ "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 337, - "proxy.ts :: proxy": 499 + "proxy.ts :: proxy": 498 } } diff --git a/src/components/timeline/__tests__/lab-lines.test.tsx b/src/components/timeline/__tests__/lab-lines.test.tsx new file mode 100644 index 000000000..ffb988407 --- /dev/null +++ b/src/components/timeline/__tests__/lab-lines.test.tsx @@ -0,0 +1,417 @@ +/** + * Lab markers on the timeline (#1299), as laid out and as rendered: a row + * per marker under the value lines with its readings as single points, the + * reference range behind them, a reading outside it drawn as a triangle and + * worded as outside wherever it is written out, the report it came from, + * the readings a unit left out named, and the menu's groups. All data + * invented. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { + LabExcludedNote, + LabLines, + LabSeriesTable, + labReadingsIn, +} from "../lab-lines"; +import { SelectionBar } from "../selection-bar"; +import type { SeriesValueFormat } from "../series-format"; +import { SERIES_HEIGHT } from "../series-geometry"; +import { layoutTimeline } from "../timeline-geometry"; +import { TimelineChronicle } from "../timeline-chronicle"; +import { TimelineLegend } from "../timeline-legend"; +import { labOptionGroups } from "../timeline-menus"; +import { useSeriesValueFormat } from "../use-series-value-format"; +import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +primeLocales("de"); + +const FERRITIN: TimelineLabSeries = { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Eisen", + unit: "ng/mL", + band: { low: 30, high: 400 }, + points: [ + { + id: "r1", + date: "2024-02-12", + value: 18, + sourceValue: null, + rangeStatus: "below", + referenceLow: 30, + referenceHigh: 400, + report: { + id: "d1", + title: "Befund Februar", + href: "/documents?doc=d1", + }, + }, + { + id: "r2", + date: "2025-06-01", + value: 85, + sourceValue: null, + rangeStatus: "in-range", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + { + id: "r3", + date: "2026-03-02", + value: 420, + sourceValue: null, + rangeStatus: "above", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + ], + excluded: 0, +}; + +const HBA1C: TimelineLabSeries = { + key: "lab:bm-hba1c", + biomarkerId: "bm-hba1c", + name: "HbA1c", + panel: null, + unit: "%", + band: null, + points: [ + { + id: "h1", + date: "2026-03-02", + value: 5.4, + sourceValue: null, + rangeStatus: "unknown", + referenceLow: null, + referenceHigh: null, + report: null, + }, + ], + excluded: 2, +}; + +const WINDOW = { from: "2024-01-01", to: "2026-06-30" }; + +function layout(labSeries: readonly TimelineLabSeries[]) { + const timeline = fullTimeline(); + return layoutTimeline({ + width: 900, + window: WINDOW, + lanes: timeline.lanes, + series: timeline.series, + labSeries, + bucket: "quarter", + words: wordsIn("de"), + startMissing: "?", + today: TODAY, + }); +} + +function render(node: React.ReactElement, locale: "de" | "en" = "de") { + return renderToStaticMarkup( + + {node} + , + ); +} + +/** The text a reader sees, runs kept apart. */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") { + inTag = false; + out += " "; + } else if (!inTag) out += ch; + } + return out.replace(/\s+/g, " "); +} + +function WithFormat({ + build, +}: { + build: (format: SeriesValueFormat) => React.ReactNode; +}) { + return <>{build(useSeriesValueFormat())}; +} + +describe("the lab rows' geometry", () => { + it("adds a row per marker under the value lines and leaves the rest as it was", () => { + const without = layout([]); + const withLabs = layout([FERRITIN, HBA1C]); + expect(withLabs.series).toEqual(without.series); + expect(withLabs.lanes).toEqual(without.lanes); + expect(withLabs.height - without.height).toBe(2 * SERIES_HEIGHT); + const [ferritin, hba1c] = withLabs.labSeries; + expect(ferritin.top).toBe( + without.seriesTop + without.series.length * SERIES_HEIGHT + 6, + ); + expect(hba1c.top - ferritin.top).toBe(SERIES_HEIGHT); + }); + + it("places each reading on its own day, inside the plot and inside its row", () => { + const { labSeries, scale } = layout([FERRITIN]); + const [row] = labSeries; + expect(row.points.map((p) => p.id)).toEqual(["r1", "r2", "r3"]); + for (const p of row.points) { + expect(p.x).toBeCloseTo(scale.xMid(p.date), 5); + expect(p.x).toBeGreaterThanOrEqual(scale.x0); + expect(p.x).toBeLessThanOrEqual(scale.x1); + expect(p.y).toBeGreaterThanOrEqual(row.top); + expect(p.y).toBeLessThanOrEqual(row.top + row.height); + } + // Higher values sit higher; the readings are joined in time order. + const [low, mid, high] = row.points; + expect(low.y).toBeGreaterThan(mid.y); + expect(mid.y).toBeGreaterThan(high.y); + expect(row.path.match(/[ML]/g)).toEqual(["M", "L", "L"]); + expect(row.latest?.id).toBe("r3"); + }); + + it("holds the reference range in the scale and paints it behind the points", () => { + const [row] = layout([FERRITIN]).labSeries; + expect(row.band).not.toBeNull(); + const { top, bottom } = row.band!; + expect(top).not.toBeNull(); + expect(bottom).not.toBeNull(); + // The in-range reading lies inside the band, the others outside it. + const [low, mid, high] = row.points; + expect(mid.y).toBeGreaterThan(top!); + expect(mid.y).toBeLessThan(bottom!); + expect(low.y).toBeGreaterThan(bottom!); + expect(high.y).toBeLessThan(top!); + }); + + it("draws one reading as a point with no line, and an empty window as no point", () => { + const [single] = layout([HBA1C]).labSeries; + expect(single.points).toHaveLength(1); + expect(single.path).toBe(""); + expect(single.band).toBeNull(); + expect(single.points[0].y).toBeCloseTo(single.top + single.height / 2, 5); + const [empty] = layout([{ ...FERRITIN, points: [] }]).labSeries; + expect(empty.points).toEqual([]); + expect(empty.latest).toBeNull(); + }); +}); + +describe("the lab rows as drawn", () => { + function drawn(rows: readonly TimelineLabSeries[]) { + const l = layout(rows); + return render( + + (key === FERRITIN.key ? "Ferritin" : "HbA1c")} + seriesColor={() => "var(--chart-3)"} + valueText={(value, _source, unit) => `${value} ${unit}`} + /> + , + ); + } + + it("marks a reading outside its range by shape, the way it left", () => { + const html = drawn([FERRITIN]); + expect(html).toContain('data-status="below"'); + expect(html).toContain('data-status="above"'); + // Outside the range a triangle (a path), inside it a dot. + expect(html).toMatch(/]*data-status="below"/); + expect(html).toMatch(/]*data-status="above"/); + expect(html).toMatch(/]*data-status="in-range"/); + // The band and its dashed edges, in the row's own colour. + expect(html).toContain('data-slot="timeline-lab-band-fill"'); + expect(html.match(/data-slot="timeline-lab-band-edge"/g)).toHaveLength(2); + expect(html).toContain('data-color="var(--chart-3)"'); + // The newest reading beside the name; the selected bucket's ringed. + expect(textOf(html)).toContain("420 ng/mL"); + expect(html.match(/data-slot="timeline-series-selected"/g)).toHaveLength(1); + }); + + it("says so when the window holds no reading", () => { + const html = drawn([{ ...FERRITIN, points: [] }]); + expect(textOf(html)).toContain("Keine Werte in diesem Zeitraum"); + expect(html).not.toContain('data-slot="timeline-lab-mark"'); + }); +}); + +describe("the lab readings written out", () => { + function bar(selected: string, locale: "de" | "en" = "en") { + const timeline: TimelineResponse = { + ...fullTimeline(), + bucket: "quarter", + labSeries: [FERRITIN, HBA1C], + }; + return render( + ( + + key === FERRITIN.key + ? "Ferritin" + : key === HBA1C.key + ? "HbA1c" + : key + } + seriesColor={() => "var(--chart-3)"} + seriesFormat={format} + onOpenDay={() => undefined} + /> + )} + />, + locale, + ); + } + + it("lists the bucket's readings with their day, a word outside the range and the report", () => { + const html = bar("2024-02-20"); + const text = textOf(html); + expect(text).toContain("Ferritin 18 ng/mL, below range, Feb 12"); + expect(html).toMatch( + /]*href="\/documents\?doc=d1"[^>]*>Report: Befund Februar<\/a>/, + ); + const both = textOf(bar("2026-03-10")); + expect(both).toContain("Ferritin 420 ng/mL, above range, Mar 2"); + // In range and without a range: the value alone. + expect(both).toContain("HbA1c 5.4 %, Mar 2"); + expect(both).not.toContain("HbA1c 5.4 %, above"); + }); + + it("words the verdict in the reader's language", () => { + expect(textOf(bar("2024-02-20", "de"))).toContain( + "Ferritin 18 ng/mL, unter dem Referenzbereich", + ); + }); + + it("finds the readings of a stretch on their own days", () => { + expect( + labReadingsIn([FERRITIN, HBA1C], "2026-03-02", "2026-03-02").map( + (r) => r.point.id, + ), + ).toEqual(["r3", "h1"]); + expect(labReadingsIn([FERRITIN], "2023-01-01", "2023-12-31")).toEqual([]); + }); + + it("rides on the phone's lab-day row, with no link inside the row's button", () => { + const timeline: TimelineResponse = { + ...fullTimeline(), + bucket: "quarter", + lanes: [ + { + key: "labs", + items: [ + item({ + id: "lab-day:2024-02-12", + kind: "labDay", + start: "2024-02-12", + label: "Ferritin", + sub: "1", + href: "/labs", + }), + ], + }, + ], + standing: [], + labSeries: [FERRITIN], + }; + const html = render( + ( + "var(--chart-3)"} + seriesFormat={format} + onOpenDay={() => undefined} + onEditLifeEvent={null} + /> + )} + />, + "en", + ); + const row = html.slice(html.indexOf('data-slot="timeline-chronicle-labs"')); + expect(textOf(row)).toContain("Ferritin 18 ng/mL, below range"); + expect(html).not.toContain('data-slot="timeline-lab-report"'); + }); + + it("names the readings a unit left out, and lists every reading for a screen reader", () => { + const note = textOf( + render( + (key === HBA1C.key ? "HbA1c" : "Ferritin")} + />, + "en", + ), + ); + expect(note).toContain("HbA1c: 2 values in another unit are not drawn"); + expect(note).not.toContain("Ferritin"); + + const table = render( + "Ferritin"} + valueText={(value, _source, unit) => `${value} ${unit}`} + intl="en-GB" + />, + "en", + ); + const rows = table.match(//g) ?? []; + expect(rows).toHaveLength(4); + expect(textOf(table)).toContain( + "18 ng/mL, below range, reference 30–400 ng/mL, Report: Befund Februar", + ); + }); + + it("explains its marks in the legend only when a lab line is drawn", () => { + const without = render(); + expect(without).not.toContain("timeline-legend-lab"); + const withLabs = textOf( + render( + , + "en", + ), + ); + expect(withLabs).toContain("Lab value, as measured"); + expect(withLabs).toContain("Outside the reference range"); + expect(withLabs).toContain("Reference range"); + }); +}); + +describe("the lab markers in the menu", () => { + it("groups by the labs list's group, named groups first, in the reader's order", () => { + const groups = labOptionGroups( + [ + { key: "lab:c", name: "Zink", panel: null }, + { key: "lab:b", name: "Transferrin", panel: "Eisen" }, + { key: "lab:a", name: "Ferritin", panel: "Eisen" }, + { key: "lab:d", name: "Ärzte-Wert", panel: "Blutbild" }, + { key: "lab:a", name: "Ferritin", panel: "Eisen" }, + ], + "de-DE", + ); + expect(groups).toEqual([ + { panel: "Blutbild", keys: ["lab:d"] }, + { panel: "Eisen", keys: ["lab:a", "lab:b"] }, + { panel: null, keys: ["lab:c"] }, + ]); + }); +}); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 8934caa50..8c6e441fb 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -105,6 +105,8 @@ const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`; /** Every timeline item kind, with every code its second line can carry. */ function everyKind(): TimelineResponse { nextDay = 1; + // Taken in its place among the days, so the other items keep theirs. + let labDay = ""; const life: TimelineItem[] = [ ...LIFE_EVENT_CATEGORIES.map((category, i) => item({ @@ -235,7 +237,7 @@ function everyKind(): TimelineResponse { item({ id: "lab-day", kind: "labDay", - start: day(), + start: (labDay = day()), label: "Kreatinin, Kalium, Natrium", sub: "5", }), @@ -258,7 +260,31 @@ function everyKind(): TimelineResponse { standing: [], series: [], availableSeries: [], - labSeries: [], + // A lab line (#1299) with a reading of every range verdict on the lab + // day: each is worded, none reaches the screen as its code. + labSeries: [ + { + key: "lab:bm-k", + biomarkerId: "bm-k", + name: "Kalium", + panel: null, + unit: "mmol/L", + band: { low: 3.5, high: 5.1 }, + points: (["below", "in-range", "above", "unknown"] as const).map( + (rangeStatus, i) => ({ + id: `k${i}`, + date: labDay, + value: 3 + i, + sourceValue: null, + rangeStatus, + referenceLow: 3.5, + referenceHigh: 5.1, + report: null, + }), + ), + excluded: 1, + }, + ], availableLabs: [], notable: [], }; @@ -318,6 +344,10 @@ describe.each(["de", "en"])("the timeline in %s", (locale) => { ), ); expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values"); + expect(text).toContain( + locale === "de" ? "über dem Referenzbereich" : "above range", + ); + expect(text).not.toMatch(/in-range|unknown/); expect(rawCodes(text)).toEqual([]); expect(text).not.toContain(MIDDLE_DOT); }); diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index e3eee440f..6d241dfca 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -19,7 +19,6 @@ import { itemsInPeriod, latestDataDate, layoutLane, - layoutSeries, layoutTimeline, stepSelection, todayLabelFits, @@ -28,6 +27,7 @@ import { type LaneLayout, type TimelineLayout, } from "../timeline-geometry"; +import { layoutSeries } from "../series-geometry"; import { itemLine } from "../item-words"; import { dayNumber } from "../timeline-dates"; import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; diff --git a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx new file mode 100644 index 000000000..1be1f5a7f --- /dev/null +++ b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx @@ -0,0 +1,297 @@ +/** + * Lab markers on the timeline page (#1299), in a real browser: the page as + * mounted, its reads answered by a stubbed network. + * + * - A stored choice that names a lab marker reaches the request, and the + * marker is drawn as its readings under the value lines. + * - The values menu offers the record's markers under their groups; + * choosing one adds it to the stored list and to the next request, + * unchoosing removes it from both. + * - With the labs module off, no marker is offered or requested. + * - A reading's point reads on its own by keyboard: day, value, the word + * for a reading outside its range, the range and the report. + * - At 390 px the page does not scroll sideways, the values menu is an + * icon button with a name, and the lab day's row lists the readings. + * + * All data invented. + */ +import { describe, expect, it } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import type { TimelineResponse } from "@/lib/day/contract"; + +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { setSearch } from "../../../../tests/browser/url"; +import { TimelineView } from "../timeline-view"; + +const VALUES_KEY = "healthlog.timeline.values"; + +function timeline(values: readonly string[]): TimelineResponse { + const ferritin = values.includes("lab:bm-ferritin"); + return { + zoom: "all", + range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" }, + bucket: "quarter", + lanes: [ + { + key: "labs", + items: [ + { + id: "lab-day:2024-02-12", + kind: "labDay", + start: "2024-02-12", + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: "Ferritin", + sub: "1", + href: "/labs", + group: null, + }, + { + id: "lab-day:2026-03-02", + kind: "labDay", + start: "2026-03-02", + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: "Ferritin, HbA1c", + sub: "2", + href: "/labs", + group: null, + }, + ], + }, + ], + standing: [], + series: values.includes("WEIGHT") + ? [ + { + key: "WEIGHT", + unit: "kg", + points: [ + { t: "2025-01-01", mean: 82.4, count: 6 }, + { t: "2025-04-01", mean: 81.9, count: 5 }, + ], + }, + ] + : [], + availableSeries: ["WEIGHT"], + labSeries: ferritin + ? [ + { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + band: { low: 30, high: 400 }, + points: [ + { + id: "r1", + date: "2024-02-12", + value: 18, + sourceValue: null, + rangeStatus: "below", + referenceLow: 30, + referenceHigh: 400, + report: { + id: "d1", + title: "February report", + href: "/documents?doc=d1", + }, + }, + { + id: "r2", + date: "2026-03-02", + value: 120, + sourceValue: null, + rangeStatus: "in-range", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + ], + excluded: 0, + }, + ] + : [], + availableLabs: [ + { + key: "lab:bm-hba1c", + biomarkerId: "bm-hba1c", + name: "HbA1c", + panel: "Glucose", + unit: "%", + }, + { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + }, + ], + notable: [], + }; +} + +/** The `values` a request named, as a list. */ +function valuesOf(path: string): string[] { + const raw = new URL(path, window.location.origin).searchParams.get("values"); + return raw ? raw.split(",") : []; +} + +function stubTimeline(): FetchStub { + return stubFetch() + .on("GET", "/api/timeline", (request) => ({ + data: timeline(valuesOf(request.path)), + })) + .on("GET", "/api/timeline/readiness", { + data: { verdict: "carries", since: "2024-01-01", lanes: [] }, + }) + .on("GET", "/api/life-events", { data: { events: [] } }); +} + +async function mount( + stored: string[] | null, + modules: Record = { timeline: true, labs: true }, +) { + if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored)); + setSearch("?zoom=all"); + const net = stubTimeline(); + const { screen } = await renderWithApp(, { + user: { timezone: "Europe/Berlin", modules }, + }); + return { net, screen }; +} + +function lastTimelineValues(net: FetchStub): string[] { + const calls = net.calls("GET", "/api/timeline"); + return valuesOf(calls[calls.length - 1]?.path ?? ""); +} + +describe("lab lines on the timeline page", () => { + it("asks for a stored lab marker and draws it as its readings", async () => { + await page.viewport(1280, 900); + const { net } = await mount(["WEIGHT", "lab:bm-ferritin"]); + await expect + .poll( + () => + document.querySelectorAll( + '[data-lab-series="lab:bm-ferritin"] [data-slot="timeline-lab-mark"]', + ).length, + ) + .toBe(2); + expect(lastTimelineValues(net)).toEqual(["WEIGHT", "lab:bm-ferritin"]); + const below = document.querySelector( + '[data-lab-series="lab:bm-ferritin"] [data-status="below"]', + ); + expect(below?.tagName.toLowerCase()).toBe("path"); + // The value line keeps its own row above it. + expect(document.querySelector('[data-series="WEIGHT"]')).not.toBeNull(); + // The name leads to the marker's page. + const link = document.querySelector( + '[data-slot="timeline-series-link"][data-series="lab:bm-ferritin"]', + ); + expect(link?.getAttribute("href")).toBe("/labs/bm-ferritin"); + }); + + it("offers the record's markers under their groups, and a choice persists both ways", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT"]); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + await trigger.click(); + await expect.element(screen.getByText("Lab values")).toBeVisible(); + await expect.element(screen.getByText("Glucose")).toBeVisible(); + await expect.element(screen.getByText("Iron")).toBeVisible(); + const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" }); + await expect.element(ferritin).toHaveAttribute("aria-checked", "false"); + await ferritin.click(); + await expect.element(ferritin).toHaveAttribute("aria-checked", "true"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([ + "WEIGHT", + "lab:bm-ferritin", + ]); + await expect + .poll(() => lastTimelineValues(net)) + .toEqual(["WEIGHT", "lab:bm-ferritin"]); + + await ferritin.click(); + await expect.element(ferritin).toHaveAttribute("aria-checked", "false"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([ + "WEIGHT", + ]); + await expect.poll(() => lastTimelineValues(net)).toEqual(["WEIGHT"]); + }); + + it("neither offers nor asks for a marker with the labs module off", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT", "lab:bm-ferritin"], { + timeline: true, + labs: false, + }); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + expect(lastTimelineValues(net)).toEqual(["WEIGHT"]); + await trigger.click(); + await expect + .element(screen.getByRole("menuitemcheckbox", { name: "Weight" })) + .toBeVisible(); + expect(document.querySelector('[data-slot="timeline-values-labs"]')).toBe( + null, + ); + }); + + it("reads a reading's point on its own by keyboard", async () => { + await page.viewport(1280, 900); + await mount(["lab:bm-ferritin"]); + const target = () => + document.querySelector( + '[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="r1"]', + ); + await expect.poll(() => target() !== null).toBe(true); + expect(target()!.getAttribute("aria-label")).toBe( + "Ferritin, Feb 12, 2024: 18 ng/mL, below range, reference 30–400 ng/mL, Report: February report", + ); + // One tab stop per line, on its newest reading; Left walks back. + target()! + .parentElement?.querySelector('[data-t="r2"]') + ?.focus(); + await userEvent.keyboard("{ArrowLeft}"); + expect(document.activeElement).toBe(target()); + const tip = document.querySelector('[data-slot="timeline-point-tip"]'); + expect(tip?.textContent).toContain("18 ng/mL"); + expect(tip?.textContent).toContain("below range"); + expect(tip?.textContent).toContain("February report"); + }); + + it("fits a phone: no sideways scroll, a named values button, the readings on the lab day", async () => { + await page.viewport(390, 844); + const { screen } = await mount(["lab:bm-ferritin"]); + await expect + .element(screen.getByRole("button", { name: "Values (1)" })) + .toBeVisible(); + // Newest first, each lab day with its own readings. + await expect + .poll(() => + [ + ...document.querySelectorAll('[data-slot="timeline-chronicle-labs"]'), + ].map((el) => el.textContent), + ) + .toEqual(["Ferritin 120 ng/mL", "Ferritin 18 ng/mL, below range"]); + const root = document.documentElement; + expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth); + const mobile = document.querySelector( + '[data-slot="timeline-mobile"]', + )!; + expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 80ac9f356..59222150b 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -24,13 +24,10 @@ import { SERIES_PALETTE, assignSeriesColors } from "../series-colors"; import type { SeriesValueFormat } from "../series-format"; import { SeriesLines } from "../timeline-chart"; import { TimelineChronicle } from "../timeline-chronicle"; -import { - SERIES_GAP, - SERIES_HEIGHT, - layoutTimeline, -} from "../timeline-geometry"; +import { SERIES_GAP, layoutTimeline } from "../timeline-geometry"; +import { SERIES_HEIGHT } from "../series-geometry"; import { ValueSeriesMenu } from "../timeline-menus"; -import { TimelineLegend } from "../timeline-view"; +import { TimelineLegend } from "../timeline-legend"; import { useSeriesValueFormat } from "../use-series-value-format"; import { item, wordsIn } from "./timeline-fixture"; diff --git a/src/components/timeline/lab-lines.tsx b/src/components/timeline/lab-lines.tsx new file mode 100644 index 000000000..619f627bc --- /dev/null +++ b/src/components/timeline/lab-lines.tsx @@ -0,0 +1,540 @@ +"use client"; + +/** + * Lab markers on the timeline (#1299): their rows under the value lines, + * their readings in the selection bar and the phone chronicle, and their + * table for a screen reader. + * + * A lab line is its readings, one point each on the day of the draw, never a + * mean. Each row has its own scale, which holds the reference range as well + * as the points, and the range is painted behind them as the labs page + * paints it: a faint band with dashed edges, in the row's own colour. A + * reading outside its range is a triangle pointing the way it left (up above + * the range, down below it) and says so in words wherever it is written out, + * so the colour is never the only sign. The numbers read through the labs + * page's own formatter, so a reading keeps every digit it was stored with. + */ +import { useCallback } from "react"; +import Link from "next/link"; + +import { useLabNumber } from "@/components/labs/use-lab-format"; +import { InlineAction } from "@/components/ui/inline-action"; +import type { + TimelineBucket, + TimelineLabPoint, + TimelineLabSeries, +} from "@/lib/day/contract"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { ChronicleEntry } from "./chronicle-model"; +import { TIMELINE_LAB_RANGE_KEY } from "./label-keys"; +import { + bucketAfter, + dayKey, + dayNumber, + formatDayMonth, + formatDayMonthYear, +} from "./timeline-dates"; +import { seriesNameLines, type TimelineLayout } from "./timeline-geometry"; + +/** "18 ng/mL", every digit the reading holds, in the reader's locale. */ +export type LabValueText = ( + value: number, + sourceValue: number | null, + unit: string, +) => string; + +export function useLabValueText(): LabValueText { + const labNumber = useLabNumber(); + return useCallback( + (value, sourceValue, unit) => + `${labNumber(value, sourceValue)} ${unit}`.trim(), + [labNumber], + ); +} + +/** "below range" / "above range", or null for a reading in range. */ +export function useLabVerdict(): ( + status: TimelineLabPoint["rangeStatus"], +) => string | null { + const { t } = useTranslations(); + return useCallback( + (status) => + status === "below" || status === "above" + ? t(TIMELINE_LAB_RANGE_KEY[status]) + : null, + [t], + ); +} + +/** "Reference 30–400 ng/mL", or null without a range. */ +export function useLabReference(): ( + point: Pick, + unit: string, +) => string | null { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + return useCallback( + (point, unit) => { + const range = formatReferenceRange( + point.referenceLow, + point.referenceHigh, + (v) => labNumber(v), + ); + return range + ? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() }) + : null; + }, + [t, labNumber], + ); +} + +/** The readings of every lab line whose day lies in `[from, to]`. */ +export function labReadingsIn( + labSeries: readonly TimelineLabSeries[], + from: string, + to: string, +): Array<{ key: string; unit: string; point: TimelineLabPoint }> { + return labSeries.flatMap((s) => + s.points + .filter((p) => p.date >= from && p.date <= to) + .map((point) => ({ key: s.key, unit: s.unit, point })), + ); +} + +/* ─── The rows on the chart ───────────────────────────────────────────────── */ + +/** Where a row's name and latest value start, as on the value lines. */ +const TEXT_X = 16; + +/** + * A reading's mark: a dot in range (or without one), a triangle pointing up + * above it and down below it. Every mark takes the row's `currentColor`. + */ +export function LabPointGlyph({ + x, + y, + status, +}: { + x: number; + y: number; + status: TimelineLabPoint["rangeStatus"]; +}) { + if (status === "above" || status === "below") { + const d = + status === "above" + ? `M${x} ${y - 4.5}L${x + 4.5} ${y + 3.5}L${x - 4.5} ${y + 3.5}Z` + : `M${x} ${y + 4.5}L${x + 4.5} ${y - 3.5}L${x - 4.5} ${y - 3.5}Z`; + return ( + + ); + } + return ( + + ); +} + +export function LabLines({ + layout, + selectedBucket, + bucket, + seriesLabel, + seriesColor, + valueText, +}: { + layout: Pick< + TimelineLayout, + "labSeries" | "series" | "seriesTop" | "scale" | "width" + >; + /** The first day of the selected bucket: its readings are ringed. */ + selectedBucket: string | null; + bucket: TimelineBucket; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + valueText: LabValueText; +}) { + const { t } = useTranslations(); + const { scale, width } = layout; + const rows = layout.labSeries; + const selectedTo = selectedBucket + ? dayKey(dayNumber(bucketAfter(selectedBucket, bucket)) - 1) + : null; + const nameLines = (key: string) => + seriesNameLines(seriesLabel(key), scale.x0 - 12 - TEXT_X, 13); + return ( + <> + {/* The hairline above the rows, when no value line drew it already. */} + {layout.series.length === 0 && rows.length > 0 && ( + + )} + {rows.map((row) => { + const color = seriesColor(row.key); + const lines = nameLines(row.key); + return ( + + {row.band && ( + + {row.band.top !== null && row.band.bottom !== null && ( + + )} + {[row.band.top, row.band.bottom].map((edge, i) => + edge === null ? null : ( + + ), + )} + + )} + + + {lines.map((line, i) => ( + + {line} + + ))} + + + {row.latest === null + ? t("timeline.values.noneInWindow") + : valueText(row.latest.value, row.latest.sourceValue, row.unit)} + + {row.path && ( + + )} + {row.points.map((point) => ( + + + + ))} + {selectedBucket !== null && + selectedTo !== null && + row.points + .filter( + (point) => + point.date >= selectedBucket && point.date <= selectedTo, + ) + .map((point) => ( + + ))} + + ); + })} + + ); +} + +/* ─── Written out ─────────────────────────────────────────────────────────── */ + +/** + * Lab readings as a line of their own under the means: "● Ferritin 18 ng/mL, + * below range, 12 Feb." with the report it came from as a link. Each reading + * is one unit that wraps only between units, like a means line. + */ +export function LabReadingsLine({ + readings, + label, + seriesColor, + valueText, + showDate, + links = true, + intl, +}: { + readings: ReturnType; + label: (key: string) => string; + seriesColor: (key: string) => string; + valueText: LabValueText; + /** The day beside each reading: where a line covers more than one day. */ + showDate: boolean; + /** + * The report as a link. Off inside a row that is itself a button (a link + * cannot sit inside one); the day the row opens names the report. + */ + links?: boolean; + intl: string; +}) { + const { t } = useTranslations(); + const verdict = useLabVerdict(); + return readings.map(({ key, unit, point }, i) => { + const words = [ + `${label(key)} ${valueText(point.value, point.sourceValue, unit)}`, + verdict(point.rangeStatus), + showDate ? formatDayMonth(point.date, intl) : null, + ] + .filter(Boolean) + .join(", "); + return ( + + {i > 0 ? , : null} + + ); + }); +} + +/** + * A lab day's row in the phone chronicle lists the chosen lab lines' + * readings of that day under its names. The row is a button that opens the + * day, so the report is named there, not linked here. + */ +export function ChronicleLabReadings({ + entry, + labSeries, + seriesColor, + className, +}: { + entry: ChronicleEntry; + labSeries: readonly TimelineLabSeries[]; + seriesColor: (key: string) => string; + /** The row's means-line classes, so both read alike. */ + className: string; +}) { + const { locale } = useTranslations(); + const valueText = useLabValueText(); + if ( + entry.kind !== "item" || + entry.item.kind !== "labDay" || + entry.role === "end" + ) { + return null; + } + const readings = labReadingsIn(labSeries, entry.date, entry.date); + if (readings.length === 0) return null; + return ( + + labSeries.find((s) => s.key === key)?.name ?? key} + seriesColor={seriesColor} + valueText={valueText} + showDate={false} + links={false} + intl={resolveIntlLocale(locale)} + /> + + ); +} + +/** + * A lab line with readings left out because their unit does not convert into + * the marker's says so, once, under the chart (and over the phone list): + * nothing is dropped without a word. + */ +export function LabExcludedNote({ + labSeries, + label, + block = false, +}: { + labSeries: readonly TimelineLabSeries[]; + label: (key: string) => string; + /** A line of its own (the phone list), not runs inside the legend. */ + block?: boolean; +}) { + const { tCount } = useTranslations(); + const lines = labSeries.filter((s) => s.excluded > 0); + if (lines.length === 0) return null; + const notes = lines.map((s) => ( + + {tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })} + + )); + return block ? ( +
+ {notes} +
+ ) : ( + notes + ); +} + +/** + * The lab lines as a table, for a screen reader: one row per reading, its + * marker, day, value, verdict, range and report. Visually hidden by a + * wrapper, as the other chart tables are. + */ +export function LabSeriesTable({ + labSeries, + window, + label, + valueText, + intl, +}: { + labSeries: readonly TimelineLabSeries[]; + window: { from: string; to: string }; + label: (key: string) => string; + valueText: LabValueText; + intl: string; +}) { + const { t } = useTranslations(); + const verdict = useLabVerdict(); + const reference = useLabReference(); + const rows = labReadingsIn(labSeries, window.from, window.to).sort((a, b) => + a.point.date < b.point.date ? 1 : -1, + ); + if (rows.length === 0) return null; + return ( +
+ + + + + + + + + + + {rows.map(({ key, unit, point }) => ( + + + + + + ))} + +
{t("timeline.table.labsCaption")}
{t("timeline.table.marker")}{t("timeline.table.date")}{t("timeline.table.value")}
{label(key)}{formatDayMonthYear(point.date, intl)} + {[ + valueText(point.value, point.sourceValue, unit), + verdict(point.rangeStatus), + reference(point, unit), + point.report + ? point.report.title + ? t("timeline.labs.report", { + title: point.report.title, + }) + : t("timeline.labs.reportUntitled") + : null, + ] + .filter(Boolean) + .join(", ")} +
+
+ ); +} diff --git a/src/components/timeline/label-keys.ts b/src/components/timeline/label-keys.ts index f4de96e35..06d50f887 100644 --- a/src/components/timeline/label-keys.ts +++ b/src/components/timeline/label-keys.ts @@ -54,6 +54,17 @@ export const TIMELINE_LEGEND_MEAN_KEY: Readonly< day: "timeline.legendMeanDay", }; +/** + * A lab reading outside its reference range, worded beside the value + * (#1299). In range and unknown read as the value alone. + */ +export const TIMELINE_LAB_RANGE_KEY: Readonly< + Record<"below" | "above", string> +> = { + below: "timeline.labs.below", + above: "timeline.labs.above", +}; + /** The two blood pressure lines read as their own names on the chart. */ export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly< Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string> diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 9c821c9af..282fe79eb 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -20,7 +20,10 @@ * above it, not in a box of its own. An entry's parts are joined by commas, * a period still open reads "since 3 Mar." (it needs no "ongoing"), and a * medication is one entry however many of its items touch the bucket - * (`medicationPeriodItem`). + * (`medicationPeriodItem`). The chosen lab lines' readings in the bucket + * follow the means, each as measured, with its day where the bucket is + * longer than one, a word where it lies outside its range, and a link to the + * report it was read from (#1299). */ import type { TimelineResponse } from "@/lib/day/contract"; import { resolveIntlLocale } from "@/lib/format-locale"; @@ -38,6 +41,7 @@ import { formatMonthYear, } from "./timeline-dates"; import { useItemWords } from "./item-words"; +import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines"; import { itemsInPeriod } from "./timeline-geometry"; import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart"; import { @@ -82,9 +86,12 @@ export function SelectionBar({ const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1); const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today); const means = bucketValues(timeline.series, timeline.bucket, selected); + const labReadings = labReadingsIn(timeline.labSeries, periodFrom, periodTo); + const labValueText = useLabValueText(); // A bucket with a value but no entry is not empty: the means below say // what it holds, so "No entries" is kept for a stretch with nothing. - const hasValue = means.values.some((v) => v.mean !== null); + const hasValue = + means.values.some((v) => v.mean !== null) || labReadings.length > 0; // "Ø {values}": the words around the values, so the values can carry // their colour dots. const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", { @@ -218,6 +225,18 @@ export function SelectionBar({ {meanAfter ? {meanAfter} : null}
)} + {labReadings.length > 0 && ( + + + + )} ); } diff --git a/src/components/timeline/series-geometry.ts b/src/components/timeline/series-geometry.ts new file mode 100644 index 000000000..2974adbd4 --- /dev/null +++ b/src/components/timeline/series-geometry.ts @@ -0,0 +1,245 @@ +/** + * The value lines and lab lines under the timeline's lanes, as pure + * geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks + * them under the lanes; each row is laid out here on its own scale. + */ +import type { + TimelineBucket, + TimelineLabPoint, + TimelineLabSeries, + TimelineSeries, +} from "@/lib/day/contract"; + +import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates"; +import type { Scale, TimeWindow } from "./timeline-geometry"; + +/** Height of one value line, label included. */ +export const SERIES_HEIGHT = 60; + +/* ─── Value lines ─────────────────────────────────────────────────────────── */ + +export interface SeriesPointLayout { + /** The bucket's first day. */ + t: string; + mean: number; + /** Readings behind the mean. */ + count: number; + x: number; + y: number; + /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */ + thin: boolean; +} + +export interface SeriesLayout { + key: string; + unit: string | null; + top: number; + height: number; + /** SVG path data of the solid runs; empty when no two points touch. */ + path: string; + /** SVG path data of the dashed bridges over short gaps; may be empty. */ + bridges: string; + /** The most recent mean inside the window, or null. */ + latest: number | null; + points: SeriesPointLayout[]; +} + +/** A bucket mean from fewer readings than this is drawn hollow. */ +export const THIN_BUCKET_COUNT = 3; + +/** + * The most missing buckets a dashed bridge spans. Beyond it the line stops + * and starts again, so a long silence reads as one and not as a trend. + * + * The rule is one length, about six months between the two points a bridge + * joins, never more, and never more than a third of what the zoom shows: + * one missing quarter (the points are six months apart), two missing months + * in a year (three months apart, a sixth of the window) and four missing + * weeks in three months (five weeks apart, the window's third). A longer + * bridge would draw a slope through a stretch where nothing was measured. + */ +export const MAX_BRIDGED_GAP: Readonly> = { + quarter: 1, + month: 2, + week: 4, + // Days come only with a chosen range under six weeks. Three missing days + // (points four days apart) bridge a weekend away or a forgotten cuff; + // a longer silence in a window that short is a break worth seeing, and + // the bridge stays well under a third of even a two-week range. + day: 3, +}; + +/** + * One neutral line per series, scaled to its own range inside the window. + * A point sits in the middle of its bucket, and every bucket with a reading + * gets its point, so one month with no neighbours stays visible. Missing + * buckets stay missing: a short gap is bridged by a dashed stroke + * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none + * between them, a longer one breaks the line. + */ +export function layoutSeries( + series: readonly TimelineSeries[], + top: number, + window: TimeWindow, + scale: Scale, + bucket: TimelineBucket, +): SeriesLayout[] { + return series.map((s, index) => { + const rowTop = top + index * SERIES_HEIGHT + 6; + const height = SERIES_HEIGHT - 16; + // A bucket that starts before the window still belongs to it while any + // of its days lie inside. + const inside = s.points + .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from) + .sort((a, b) => (a.t < b.t ? -1 : 1)); + if (inside.length === 0) { + return { + key: s.key, + unit: s.unit, + top: rowTop, + height, + path: "", + bridges: "", + latest: null, + points: [], + }; + } + let lo = Math.min(...inside.map((p) => p.mean)); + let hi = Math.max(...inside.map((p) => p.mean)); + const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); + lo -= pad; + hi += pad; + const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); + const points = inside.map((p) => { + const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2; + const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid))); + return { + t: p.t, + mean: p.mean, + count: p.count, + x, + y: y(p.mean), + thin: p.count < THIN_BUCKET_COUNT, + }; + }); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person + const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; + let path = ""; + let bridges = ""; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const cur = points[i]; + const missing = + bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1; + if (missing === 0) { + // Continue the run, or open a new one at the previous point. + path += path.endsWith(xy(prev)) + ? `L${xy(cur)}` + : `M${xy(prev)}L${xy(cur)}`; + } else if (missing <= MAX_BRIDGED_GAP[bucket]) { + bridges += `M${xy(prev)}L${xy(cur)}`; + } + } + return { + key: s.key, + unit: s.unit, + top: rowTop, + height, + path, + bridges, + latest: inside[inside.length - 1].mean, + points, + }; + }); +} + +/* ─── Lab lines ───────────────────────────────────────────────────────────── */ + +export interface LabPointLayout extends TimelineLabPoint { + x: number; + y: number; +} + +export interface LabSeriesLayout { + key: string; + biomarkerId: string; + unit: string; + top: number; + height: number; + /** SVG path data joining the points in time order; empty under two. */ + path: string; + /** + * The reference range as y positions inside the row: `top` / `bottom` of + * the band, null on a side the range leaves open. + */ + band: { top: number | null; bottom: number | null } | null; + /** The newest point inside the window, or null. */ + latest: LabPointLayout | null; + points: LabPointLayout[]; + excluded: number; +} + +/** + * One row per lab marker, below the value lines and in their rhythm, each on + * its own scale. A lab line is its readings: a point per reading on the day + * of the draw, joined in time order by a thin stroke, never a mean and never + * a value between readings (#1299). The scale holds the points and the + * reference range together, as the labs page's chart does, so the band + * behind the points is always in view and a point outside it reads as + * outside at a glance. + */ +export function layoutLabSeries( + series: readonly TimelineLabSeries[], + top: number, + window: TimeWindow, + scale: Scale, +): LabSeriesLayout[] { + return series.map((s, index) => { + const rowTop = top + index * SERIES_HEIGHT + 6; + const height = SERIES_HEIGHT - 16; + const inside = s.points.filter( + (p) => p.date >= window.from && p.date <= window.to, + ); + const bounds = [s.band?.low, s.band?.high].filter( + (b): b is number => typeof b === "number", + ); + const span = [...inside.map((p) => p.value), ...bounds]; + let lo = span.length > 0 ? Math.min(...span) : 0; + let hi = span.length > 0 ? Math.max(...span) : 1; + // A lone value, or a range of one number, still needs a span to sit in. + const pad = hi > lo ? (hi - lo) * 0.12 : Math.abs(hi) * 0.1 || 1; + lo -= pad; + hi += pad; + const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); + const points = inside.map((p) => ({ + ...p, + x: Math.min(scale.x1, Math.max(scale.x0, scale.xMid(p.date))), + y: y(p.value), + })); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person + const xy = (p: LabPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; + const path = + points.length > 1 + ? points.map((p, i) => `${i === 0 ? "M" : "L"}${xy(p)}`).join("") + : ""; + const band = + s.band && (s.band.low !== null || s.band.high !== null) + ? { + top: s.band.high === null ? null : y(s.band.high), + bottom: s.band.low === null ? null : y(s.band.low), + } + : null; + return { + key: s.key, + biomarkerId: s.biomarkerId, + unit: s.unit, + top: rowTop, + height, + path, + band, + latest: points.at(-1) ?? null, + points, + excluded: s.excluded, + }; + }); +} diff --git a/src/components/timeline/series-point-tips.tsx b/src/components/timeline/series-point-tips.tsx index 84230b7cb..e707103cf 100644 --- a/src/components/timeline/series-point-tips.tsx +++ b/src/components/timeline/series-point-tips.tsx @@ -23,6 +23,11 @@ * * The card is placed above the point and kept inside the chart's width * (`tipLeft`), so it never adds a horizontal scroll of its own. + * + * A lab line's point (#1299) is one reading, not a mean: its card names the + * day of the draw, the value, whether it lies outside its reference range + * and the range itself, and the report it was read from. A tap pins the card + * with a link to that report beside "View the whole day". */ import { useEffect, @@ -30,13 +35,22 @@ import { useRef, useState, type KeyboardEvent, + type ReactNode, } from "react"; +import Link from "next/link"; -import { RichChartTooltip } from "@/components/charts/chart-tooltip"; +import { + RichChartTooltip, + type RichTooltipRow, +} from "@/components/charts/chart-tooltip"; import { TooltipDayAction } from "@/components/day/chart-day"; +import { InlineAction } from "@/components/ui/inline-action"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; +import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines"; import { formatSeriesValue, type SeriesValueFormat } from "./series-format"; +import { formatDayMonthYear } from "./timeline-dates"; import type { TimelineLayout } from "./timeline-geometry"; /** Hit target across, in px: bigger than the dot, a comfortable aim. */ @@ -61,6 +75,21 @@ interface Active { pinned: boolean; } +/** One point's target and card, a value line's or a lab line's alike. */ +interface TipPoint { + /** The point's id within its line: a bucket start, or a reading's id. */ + t: string; + x: number; + y: number; + /** The day a second tap, Enter or a double click opens. */ + day: string; + ariaLabel: string; + label: string; + row: RichTooltipRow; + /** Under the row: the report a reading came from. */ + report: { href: string; text: string } | null; +} + export function SeriesPointTips({ layout, today, @@ -81,7 +110,6 @@ export function SeriesPointTips({ onSelect: (date: string) => void; onOpenDay: (date: string) => void; }) { - const { tCount } = useTranslations(); const [active, setActive] = useState(null); // Per line, the point that holds its one tab stop. const [stops, setStops] = useState>({}); @@ -115,21 +143,24 @@ export function SeriesPointTips({ return () => document.removeEventListener("pointerdown", away); }, [pinned]); - // The first day of a point's stretch that has happened, the day it opens. - const dayOf = (t: string) => (t > today ? today : t); + const lines = useTipLines({ + layout, + today, + seriesLabel, + seriesColor, + fmt, + bucketLabel, + }); - const series = layout.series.find((s) => s.key === active?.key); - const point = series?.points.find((p) => p.t === active?.t); - - const words = (key: string, t: string, mean: number, unit: string | null) => - `${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`; + const line = lines.find((l) => l.key === active?.key); + const point = line?.points.find((p) => p.t === active?.t); function move( event: KeyboardEvent, key: string, index: number, ) { - const points = layout.series.find((s) => s.key === key)?.points ?? []; + const points = lines.find((l) => l.key === key)?.points ?? []; let next: number | null = null; if (event.key === "ArrowLeft") next = Math.max(0, index - 1); else if (event.key === "ArrowRight") @@ -149,7 +180,7 @@ export function SeriesPointTips({ return (
- {layout.series.map((s) => + {lines.map((s) => s.points.map((p, i) => { const id = `${s.key}:${p.t}`; const stop = stops[s.key] ?? s.points.at(-1)?.t; @@ -165,7 +196,7 @@ export function SeriesPointTips({ data-series={s.key} data-t={p.t} tabIndex={p.t === stop ? 0 : -1} - aria-label={`${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`} + aria-label={p.ariaLabel} onPointerDown={(event) => { pointerType.current = event.pointerType; event.stopPropagation(); @@ -189,7 +220,7 @@ export function SeriesPointTips({ event.stopPropagation(); if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); return; } move(event, s.key, i); @@ -197,7 +228,7 @@ export function SeriesPointTips({ onClick={(event) => { event.stopPropagation(); if (pointerType.current === "mouse") { - onSelect(dayOf(p.t)); + onSelect(p.day); return; } // A tap shows the card; a second tap on the same point opens @@ -207,14 +238,14 @@ export function SeriesPointTips({ active.key === s.key && active.t === p.t ) { - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); return; } setActive({ key: s.key, t: p.t, pinned: true }); }} onDoubleClick={(event) => { event.stopPropagation(); - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); }} className="focus-visible:ring-ring/50 absolute rounded-full outline-none focus-visible:ring-2" style={{ @@ -227,11 +258,11 @@ export function SeriesPointTips({ ); }), )} - {series && point ? ( + {line && point ? (
onOpenDay(dayOf(point.t))} /> - ) : undefined - } + label={point.label} + rows={[point.row]} + action={tipAction(point, active?.pinned ?? false, () => + onOpenDay(point.day), + )} />
) : null}
); } + +/** + * Under a card's row: a reading's report (a link once the card is pinned, its + * name while it only follows the pointer) and, pinned, the way into the day. + */ +function tipAction( + point: TipPoint, + pinned: boolean, + openDay: () => void, +): ReactNode { + if (!point.report) + return pinned ? : undefined; + return ( + <> +
+ {pinned ? ( + + event.stopPropagation()} + > + {point.report.text} + + + ) : ( + + {point.report.text} + + )} +
+ {pinned ? : null} + + ); +} + +/** + * Every line's points as targets and cards, value lines first, then lab + * lines, in the order they are drawn: a value line's point names its bucket + * and the readings behind its mean, a lab line's point the day of its draw, + * its verdict, its range and its report. + */ +function useTipLines({ + layout, + today, + seriesLabel, + seriesColor, + fmt, + bucketLabel, +}: { + layout: TimelineLayout; + today: string; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + fmt: SeriesValueFormat; + bucketLabel: (t: string) => string; +}): Array<{ key: string; points: TipPoint[] }> { + const { t: tr, tCount, locale } = useTranslations(); + const intl = resolveIntlLocale(locale); + const verdict = useLabVerdict(); + const labValueText = useLabValueText(); + const reference = useLabReference(); + // The first day of a point's stretch that has happened, the day it opens. + const dayOf = (t: string) => (t > today ? today : t); + + const words = (key: string, t: string, mean: number, unit: string | null) => + `${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`; + + return [ + ...layout.series.map((s) => ({ + key: s.key, + points: s.points.map((p) => ({ + t: p.t, + x: p.x, + y: p.y, + day: dayOf(p.t), + ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`, + label: bucketLabel(p.t), + row: { + name: seriesLabel(s.key), + value: formatSeriesValue(s.key, p.mean, s.unit, fmt), + color: seriesColor(s.key), + delta: tCount("timeline.values.readings", p.count), + }, + report: null, + })), + })), + ...layout.labSeries.map((s) => ({ + key: s.key, + points: s.points.map((p) => { + const value = labValueText(p.value, p.sourceValue, s.unit); + const detail = [verdict(p.rangeStatus), reference(p, s.unit)] + .filter(Boolean) + .join(", "); + const report = p.report + ? { + href: p.report.href, + text: p.report.title + ? tr("timeline.labs.report", { title: p.report.title }) + : tr("timeline.labs.reportUntitled"), + } + : null; + const date = formatDayMonthYear(p.date, intl); + return { + t: p.id, + x: p.x, + y: p.y, + day: dayOf(p.date), + ariaLabel: [ + `${seriesLabel(s.key)}, ${date}: ${value}`, + detail, + report?.text, + ] + .filter(Boolean) + .join(", "), + label: date, + row: { + name: seriesLabel(s.key), + value, + color: seriesColor(s.key), + ...(detail ? { delta: detail } : {}), + }, + report, + }; + }), + })), + ]; +} diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 69b042257..16969fd81 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -54,28 +54,29 @@ import type { import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; -import { itemLine, useItemWords, type ItemWordsFn } from "./item-words"; -import { medicationListItems } from "./medication-rows"; +import { useItemWords, type ItemWordsFn } from "./item-words"; +import { + LabLines, + LabSeriesTable, + useLabValueText, + type LabValueText, +} from "./lab-lines"; +import { SeriesTable, TimelineTable, bucketText } from "./timeline-tables"; import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; import { - bucketAfter, bucketStart, formatAtPrecision, - formatBucket, formatMonthShort, formatMonthYear, - dayNumber, } from "./timeline-dates"; import { AXIS_HEIGHT, LANE_COLOR, dateAtPointer, estimateTextWidth, - SERIES_HEIGHT, fitText, seriesNameLines, gridTicks, - isSpan, todayLabelFits, wrapToWidth, layoutTimeline, @@ -85,6 +86,7 @@ import { type PlacedSpan, type TimeWindow, } from "./timeline-geometry"; +import { SERIES_HEIGHT } from "./series-geometry"; import { formatSeriesValue, type MeanPart, @@ -157,6 +159,7 @@ export function TimelineChart({ const { t, locale } = useTranslations(); const words = useItemWords(); const fmt = seriesFormat; + const labValueText = useLabValueText(); const intl = resolveIntlLocale(locale); const { ref, width } = useElementWidth(); @@ -172,13 +175,24 @@ export function TimelineChart({ window, lanes, series: timeline.series, + labSeries: timeline.labSeries, bucket: timeline.bucket, words, startMissing: t("timeline.startUnknown"), today, }) : null, - [width, window, lanes, timeline.series, timeline.bucket, words, t, today], + [ + width, + window, + lanes, + timeline.series, + timeline.labSeries, + timeline.bucket, + words, + t, + today, + ], ); const floor = timeline.range.dataFrom ?? window.from; @@ -266,6 +280,7 @@ export function TimelineChart({ intl={intl} t={t} fmt={fmt} + labValueText={labValueText} /> ) : ( @@ -301,6 +316,13 @@ export function TimelineChart({ fmt={fmt} intl={intl} /> + ); } @@ -320,10 +342,23 @@ export function SeriesNameLinks({ layout: NonNullable>; seriesLabel: (key: string) => string; }) { + // A lab line's name leads to its marker's page on the labs list. + const rows = [ + ...layout.series.map((s) => ({ + key: s.key, + top: s.top, + href: metricPageHref(s.key), + })), + ...layout.labSeries.map((s) => ({ + key: s.key, + top: s.top, + href: `/labs/${encodeURIComponent(s.biomarkerId)}`, + })), + ]; return ( <> - {layout.series.map((series) => { - const href = metricPageHref(series.key); + {rows.map((series) => { + const href = series.href; if (!href) return null; return ( ["t"]; fmt: SeriesValueFormat; + labValueText: LabValueText; } function ChartBody({ @@ -375,6 +411,7 @@ function ChartBody({ intl, t, fmt, + labValueText, }: ChartBodyProps) { const { scale, height, width } = layout; const todayInWindow = today >= window.from && today <= window.to; @@ -409,30 +446,12 @@ function ChartBody({ {noDataUntil !== null && ( - - - {wrapToWidth( - t("timeline.noDataYet"), - noDataUntil - scale.x0 - 12, - ).map((line, i, lines) => ( - - {line} - - ))} - + )} {/* Grid and axis labels. */} @@ -530,6 +549,16 @@ function ChartBody({ fmt={fmt} /> + {/* Lab lines (#1299): single readings, under the value lines. */} + + {/* Today. */} {todayInWindow && ( @@ -606,6 +635,46 @@ function ChartBody({ ); } +/** + * The stretch before the record's first entry, shaded and named, so an empty + * start reads as "no data yet" and not as a quiet period. + */ +function NoDataShade({ + x0, + until, + height, + label, +}: { + x0: number; + until: number; + height: number; + label: string; +}) { + return ( + + + {wrapToWidth(label, until - x0 - 12).map((line, i, lines) => ( + + {line} + + ))} + + ); +} + /** * The value lines under the lanes, each in its own colour: the swatch beside * the name, the line, the dashed bridge over a gap, the points (hollow where @@ -964,156 +1033,6 @@ export function PointMark({ ); } -/** - * The chart as a table, for a screen reader: every entry with its lane and - * dates. Visually hidden; the phone chronicle is the visible list form. - */ -function TimelineTable({ - timeline, - hiddenLanes, - intl, -}: { - timeline: TimelineResponse; - hiddenLanes: ReadonlySet; - intl: string; -}) { - const { t } = useTranslations(); - const words = useItemWords(); - const rows = timeline.lanes - .filter((lane) => !hiddenLanes.has(lane.key)) - .flatMap((lane) => - (lane.key === "medications" - ? medicationListItems(lane.items) - : lane.items - ).map((item) => ({ lane: lane.key, item })), - ) - .sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start)); - return ( - // The table is hidden by a wrapper, never by `sr-only` on the table - // itself: `overflow: hidden` does not apply to a table box, so a - // visually hidden table still laid out its rows at full height under - // the page and made `
` scroll past its last card into nothing. -
- - - - - - - - - - - - {rows.map(({ lane, item }) => ( - - - - - - - ))} - -
{t("timeline.table.caption")}
{t("timeline.table.lane")}{t("timeline.table.entry")}{t("timeline.table.start")}{t("timeline.table.end")}
{t(TIMELINE_LANE_LABEL_KEY[lane])}{itemLine(item, words, t("timeline.startUnknown"))}{formatAtPrecision(item.start, item.precision, intl)} - {!item.open && isSpan(item) && item.end - ? formatAtPrecision(item.end, item.precision, intl) - : ""} -
-
- ); -} - -/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */ -export function bucketText( - start: string, - bucket: TimelineBucket, - intl: string, - t: ReturnType["t"], -): string { - const label = formatBucket(start, bucket, intl); - return typeof label === "string" - ? label - : t("timeline.selection.range", label); -} - -/** - * The value lines as a table, for a screen reader: one row per bucket from - * the first to the last with a reading in the window, one column per - * series. A bucket without a reading says so; nothing is filled in. - */ -function SeriesTable({ - timeline, - window, - seriesLabel, - fmt, - intl, -}: { - timeline: TimelineResponse; - window: { from: string; to: string }; - seriesLabel: (key: string) => string; - fmt: SeriesValueFormat; - intl: string; -}) { - const { t, tCount } = useTranslations(); - const { bucket, series } = timeline; - const inWindow = (start: string) => - start <= window.to && bucketAfter(start, bucket) > window.from; - const starts = series - .flatMap((s) => s.points.map((p) => p.t)) - .filter(inWindow) - .sort(); - if (starts.length === 0) return null; - const rows: string[] = []; - for ( - let start = bucketStart(starts[0], bucket); - start <= starts[starts.length - 1]; - start = bucketAfter(start, bucket) - ) { - rows.push(start); - } - const byKey = new Map( - series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]), - ); - return ( - // The table is hidden by a wrapper, never by `sr-only` on the table - // itself: `overflow: hidden` does not apply to a table box, so a - // visually hidden table still laid out its rows at full height under - // the page and made `
` scroll past its last card into nothing. -
- - - - - - {series.map((s) => ( - - ))} - - - - {rows.reverse().map((start) => ( - - - {series.map((s) => { - const point = byKey.get(s.key)?.get(start); - return ( - - ); - })} - - ))} - -
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} - {seriesLabel(s.key)} -
{bucketText(start, bucket, intl, t)} - {point - ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` - : t("timeline.values.noValue")} -
-
- ); -} - /** * A means line with each value marked by its line's colour: "● 129/82 mmHg * ● 82,6 kg". The values stand apart by space, not by a separator glyph; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index f8c1ee212..ceff3aff3 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -7,6 +7,9 @@ * rails beside the rows an episode or a course spans, and empty stretches * named rather than skipped. Built from `chronicle-model.ts`. * + * A lab day's row lists the chosen lab lines' readings of that day under its + * names, each as measured (#1299); its report is a step further, in the day. + * * A dated row opens its day (the same `?day=` layer the chart hands off to); * a life event opens its edit sheet instead, because the event is the * person's own entry and that is where it is changed. @@ -42,6 +45,7 @@ import { type ChronicleRails, } from "./chronicle-model"; import { useItemWords } from "./item-words"; +import { ChronicleLabReadings } from "./lab-lines"; import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys"; import { formatAtPrecision, @@ -50,12 +54,8 @@ import { formatMonthYear, } from "./timeline-dates"; import { LANE_COLOR } from "./timeline-geometry"; -import { - MEAN_PARTS_ROW, - MeanPartsLine, - bucketText, - laneDotStyle, -} from "./timeline-chart"; +import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart"; +import { bucketText } from "./timeline-tables"; import { chronicleMeans, meanParts, @@ -131,6 +131,52 @@ function Rails({ rails }: { rails: ChronicleRails }) { ); } +/** A chronicle row's title and the muted line under it. */ +function entryText( + entry: ChronicleEntry, + { + t, + tCount, + words, + }: { + t: ReturnType["t"]; + tCount: ReturnType["tCount"]; + words: ReturnType; + }, +): { + title: string; + meta: string | null; +} { + if (entry.kind === "notable") { + return { + title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]), + meta: t("timeline.chronicle.notableMeta"), + }; + } + const { item, role } = entry; + const { label, sub, tag } = words(item); + if (role === "end") { + // A pause that ends is the medication taken up again, not ended. + return { + title: + item.kind === "pause" + ? t("day.event.resumed", { label: item.label }) + : t("timeline.chronicle.ended", { label }), + meta: entry.days + ? tCount("timeline.chronicle.afterDays", entry.days) + : null, + }; + } + // A life event's category sits on the muted line under its title. + const meta = [ + sub ?? tag ?? null, + item.startKnown ? null : t("timeline.startUnknown"), + ] + .filter(Boolean) + .join(", "); + return { title: label, meta: meta || null }; +} + export function TimelineChronicle({ timeline, today, @@ -185,40 +231,6 @@ export function TimelineChronicle({ }); }; - function entryText(entry: ChronicleEntry): { - title: string; - meta: string | null; - } { - if (entry.kind === "notable") { - return { - title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]), - meta: t("timeline.chronicle.notableMeta"), - }; - } - const { item, role } = entry; - const { label, sub, tag } = words(item); - if (role === "end") { - // A pause that ends is the medication taken up again, not ended. - return { - title: - item.kind === "pause" - ? t("day.event.resumed", { label: item.label }) - : t("timeline.chronicle.ended", { label }), - meta: entry.days - ? tCount("timeline.chronicle.afterDays", entry.days) - : null, - }; - } - // A life event's category sits on the muted line under its title. - const meta = [ - sub ?? tag ?? null, - item.startKnown ? null : t("timeline.startUnknown"), - ] - .filter(Boolean) - .join(", "); - return { title: label, meta: meta || null }; - } - return (
{chips.length > 0 && ( @@ -314,7 +326,7 @@ export function TimelineChronicle({ ); } const { entry } = row; - const { title, meta } = entryText(entry); + const { title, meta } = entryText(entry, { t, tCount, words }); const Icon = entry.kind === "notable" ? Sparkles @@ -367,6 +379,12 @@ export function TimelineChronicle({ {meta} )} + ); diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 2bb2b49f4..f6213f0ed 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -20,6 +20,7 @@ import type { TimelineBucket, TimelineItem, TimelineItemKind, + TimelineLabSeries, TimelineLane, TimelineLaneKey, TimelineSeries, @@ -27,6 +28,13 @@ import type { } from "@/lib/day/contract"; import { itemLine, type ItemWordsFn } from "./item-words"; +import { + SERIES_HEIGHT, + layoutLabSeries, + layoutSeries, + type LabSeriesLayout, + type SeriesLayout, +} from "./series-geometry"; import { doseSegments, medicationGroups, @@ -36,8 +44,6 @@ import { } from "./medication-rows"; import { addMonths, - bucketAfter, - bucketIndex, dayKey, dayNumber, endOfMonth, @@ -57,8 +63,6 @@ export const ROW_HEIGHT = 22; export const LANE_PAD_BOTTOM = 18; /** Gap between the last lane and the first value line. */ export const SERIES_GAP = 18; -/** Height of one value line, label included. */ -export const SERIES_HEIGHT = 60; /** Label font size in the lanes, px. */ export const LABEL_FONT_PX = 11; /** Estimated advance of one label character at 11 px, on the wide side. */ @@ -1051,143 +1055,6 @@ function placeLabels( return labels; } -/* ─── Value lines ─────────────────────────────────────────────────────────── */ - -export interface SeriesPointLayout { - /** The bucket's first day. */ - t: string; - mean: number; - /** Readings behind the mean. */ - count: number; - x: number; - y: number; - /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */ - thin: boolean; -} - -export interface SeriesLayout { - key: string; - unit: string | null; - top: number; - height: number; - /** SVG path data of the solid runs; empty when no two points touch. */ - path: string; - /** SVG path data of the dashed bridges over short gaps; may be empty. */ - bridges: string; - /** The most recent mean inside the window, or null. */ - latest: number | null; - points: SeriesPointLayout[]; -} - -/** A bucket mean from fewer readings than this is drawn hollow. */ -export const THIN_BUCKET_COUNT = 3; - -/** - * The most missing buckets a dashed bridge spans. Beyond it the line stops - * and starts again, so a long silence reads as one and not as a trend. - * - * The rule is one length, about six months between the two points a bridge - * joins, never more, and never more than a third of what the zoom shows: - * one missing quarter (the points are six months apart), two missing months - * in a year (three months apart, a sixth of the window) and four missing - * weeks in three months (five weeks apart, the window's third). A longer - * bridge would draw a slope through a stretch where nothing was measured. - */ -export const MAX_BRIDGED_GAP: Readonly> = { - quarter: 1, - month: 2, - week: 4, - // Days come only with a chosen range under six weeks. Three missing days - // (points four days apart) bridge a weekend away or a forgotten cuff; - // a longer silence in a window that short is a break worth seeing, and - // the bridge stays well under a third of even a two-week range. - day: 3, -}; - -/** - * One neutral line per series, scaled to its own range inside the window. - * A point sits in the middle of its bucket, and every bucket with a reading - * gets its point, so one month with no neighbours stays visible. Missing - * buckets stay missing: a short gap is bridged by a dashed stroke - * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none - * between them, a longer one breaks the line. - */ -export function layoutSeries( - series: readonly TimelineSeries[], - top: number, - window: TimeWindow, - scale: Scale, - bucket: TimelineBucket, -): SeriesLayout[] { - return series.map((s, index) => { - const rowTop = top + index * SERIES_HEIGHT + 6; - const height = SERIES_HEIGHT - 16; - // A bucket that starts before the window still belongs to it while any - // of its days lie inside. - const inside = s.points - .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from) - .sort((a, b) => (a.t < b.t ? -1 : 1)); - if (inside.length === 0) { - return { - key: s.key, - unit: s.unit, - top: rowTop, - height, - path: "", - bridges: "", - latest: null, - points: [], - }; - } - let lo = Math.min(...inside.map((p) => p.mean)); - let hi = Math.max(...inside.map((p) => p.mean)); - const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); - lo -= pad; - hi += pad; - const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); - const points = inside.map((p) => { - const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2; - const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid))); - return { - t: p.t, - mean: p.mean, - count: p.count, - x, - y: y(p.mean), - thin: p.count < THIN_BUCKET_COUNT, - }; - }); - // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person - const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; - let path = ""; - let bridges = ""; - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]; - const cur = points[i]; - const missing = - bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1; - if (missing === 0) { - // Continue the run, or open a new one at the previous point. - path += path.endsWith(xy(prev)) - ? `L${xy(cur)}` - : `M${xy(prev)}L${xy(cur)}`; - } else if (missing <= MAX_BRIDGED_GAP[bucket]) { - bridges += `M${xy(prev)}L${xy(cur)}`; - } - } - return { - key: s.key, - unit: s.unit, - top: rowTop, - height, - path, - bridges, - latest: inside[inside.length - 1].mean, - points, - }; - }); -} - /* ─── Whole chart ─────────────────────────────────────────────────────────── */ export interface TimelineLayout { @@ -1197,6 +1064,8 @@ export interface TimelineLayout { lanes: LaneLayout[]; seriesTop: number; series: SeriesLayout[]; + /** The lab lines, below the value lines (#1299). */ + labSeries: LabSeriesLayout[]; } /** Width of the lane-name column at a given chart width. */ @@ -1209,6 +1078,8 @@ export function layoutTimeline(input: { window: TimeWindow; lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; + /** Lab lines, drawn under the value lines; none by default. */ + labSeries?: readonly TimelineLabSeries[]; bucket: TimelineBucket; words: ItemWordsFn; startMissing: string; @@ -1231,7 +1102,9 @@ export function layoutTimeline(input: { lanes.push(layout); top += layout.height; } - const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0); + const labInput = input.labSeries ?? []; + const seriesTop = + top + (input.series.length + labInput.length > 0 ? SERIES_GAP : 0); const series = layoutSeries( input.series, seriesTop, @@ -1239,8 +1112,23 @@ export function layoutTimeline(input: { scale, input.bucket, ); - const height = seriesTop + series.length * SERIES_HEIGHT + 4; - return { width: input.width, height, scale, lanes, seriesTop, series }; + const labSeries = layoutLabSeries( + labInput, + seriesTop + series.length * SERIES_HEIGHT, + input.window, + scale, + ); + const height = + seriesTop + (series.length + labSeries.length) * SERIES_HEIGHT + 4; + return { + width: input.width, + height, + scale, + lanes, + seriesTop, + series, + labSeries, + }; } /* ─── Interaction ─────────────────────────────────────────────────────────── */ @@ -1364,6 +1252,7 @@ export function latestDataDate( timeline: { lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; + labSeries?: readonly TimelineLabSeries[]; }, today: string, window: TimeWindow, @@ -1391,5 +1280,9 @@ export function latestDataDate( for (const series of timeline.series) { for (const point of series.points) consider(point.t); } + // A lab reading counts on its own day. + for (const series of timeline.labSeries ?? []) { + for (const point of series.points) consider(point.date); + } return best; } diff --git a/src/components/timeline/timeline-legend.tsx b/src/components/timeline/timeline-legend.tsx new file mode 100644 index 000000000..ef1b57ad8 --- /dev/null +++ b/src/components/timeline/timeline-legend.tsx @@ -0,0 +1,193 @@ +"use client"; + +/** + * The timeline's legend (v1.42, #613; lab marks #1299). + */ +import type { TimelineBucket, TimelineLabSeries } from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +import { TIMELINE_LEGEND_MEAN_KEY } from "./label-keys"; +import { LabExcludedNote } from "./lab-lines"; + +/** + * What the marks mean. Under the selection bar and set off from it by a + * hairline. With value lines, the two marks a line can carry besides its + * points are explained by an example of each: the dashed stroke over a short + * gap without measurements, and the hollow point of a mean resting on fewer + * than three. + */ +export function TimelineLegend({ + bucket, + hasSeries, + labSeries = [], + label = (key) => key, +}: { + bucket: TimelineBucket; + hasSeries: boolean; + /** + * The lab lines drawn: their three marks are explained, and what a line + * left out is said here, where the marks are (#1299). + */ + labSeries?: readonly TimelineLabSeries[]; + label?: (key: string) => string; +}) { + const { t } = useTranslations(); + const hasLabs = labSeries.length > 0; + return ( +
+ + + + + {hasSeries && ( + <> + + + + + {t("timeline.legendGap")} + + + + {t("timeline.legendThin")} + + + )} + {hasLabs && ( + <> + + + {t("timeline.legendLabValue")} + + + + {t("timeline.legendLabOutside")} + + + + {t("timeline.legendLabRange")} + + + + )} +
+ ); +} diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index e33d583de..a20abefac 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -7,7 +7,9 @@ * * The value menu is one compact button, "Values (3)". The chosen lines are * not listed again beside it: each one is a named row under the lanes, and - * the menu marks it with its colour. + * the menu marks it with its colour. Below the measurements it offers the + * lab markers the record has values for (#1299), under the groups the labs + * list sorts them into; a lab line is chosen and stored like any other. */ import { ChartLine, ChevronDown, Layers } from "lucide-react"; @@ -16,44 +18,94 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import type { TimelineLaneKey } from "@/lib/day/contract"; +import { + timelineLabMarkerId, + type TimelineLaneKey, + type TimelineResponse, +} from "@/lib/day/contract"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; +/** Lab markers under one group of the labs list (null: no group). */ +export interface LabOptionGroup { + panel: string | null; + keys: readonly string[]; +} + export function ValueSeriesMenu({ options, + labGroups = [], selected, label, color, onChange, + iconOnly = false, }: { options: readonly string[]; + /** The record's lab markers, grouped as the labs list groups them. */ + labGroups?: readonly LabOptionGroup[]; selected: readonly string[]; label: (key: string) => string; /** A chosen line's colour, shown as a dot beside its name. */ color: (key: string) => string; onChange: (next: string[]) => void; + /** The phone toolbar's square button, named by its label. */ + iconOnly?: boolean; }) { const { t } = useTranslations(); + const option = (key: string) => { + const checked = selected.includes(key); + return ( + event.preventDefault()} + onCheckedChange={(next) => + onChange( + next ? [...selected, key] : selected.filter((k) => k !== key), + ) + } + > + {/* The dot is the line's colour on the chart; an unchosen + value has no line yet, so its place stays empty. */} + + ); + }; + const trigger = t("timeline.values.trigger", { count: selected.length }); + // A group is named once there is more than one, or when it has a name. + const namedGroups = + labGroups.length > 1 || labGroups.some((g) => g.panel !== null); return ( {t("timeline.values.menuTitle")} - {options.map((key) => { - const checked = selected.includes(key); - return ( - event.preventDefault()} - onCheckedChange={(next) => - onChange( - next ? [...selected, key] : selected.filter((k) => k !== key), - ) - } - > - {/* The dot is the line's colour on the chart; an unchosen - value has no line yet, so its place stays empty. */} - - ); - })} + {options.map(option)} + {labGroups.length > 0 && ( + <> + + + {t("timeline.labs.menuTitle")} + + {labGroups.map((group) => ( + + {namedGroups && ( + + {group.panel ?? t("timeline.labs.otherPanel")} + + )} + {group.keys.map(option)} + + ))} + + )} ); @@ -147,3 +194,102 @@ export function LayersMenu({ ); } + +/** + * The lab markers of the menu, grouped as the labs list groups them: by the + * marker's group, named groups first, each in the reader's alphabetical + * order, markers inside a group likewise. + */ +export function labOptionGroups( + markers: ReadonlyArray<{ key: string; name: string; panel: string | null }>, + intl: string, +): LabOptionGroup[] { + const collator = new Intl.Collator(intl, { sensitivity: "base" }); + const groups = new Map(); + const seen = new Set(); + for (const marker of markers) { + if (seen.has(marker.key)) continue; + seen.add(marker.key); + const panel = marker.panel?.trim() || null; + groups.set(panel, [...(groups.get(panel) ?? []), marker]); + } + return [...groups] + .sort(([a], [b]) => + a === null ? 1 : b === null ? -1 : collator.compare(a, b), + ) + .map(([panel, list]) => ({ + panel, + keys: [...list] + .sort((a, b) => collator.compare(a.name, b.name)) + .map((m) => m.key), + })); +} + +/** Each lab marker's name by its key, from what the server sent. */ +export function labMarkerNames( + timeline: Pick | undefined, +): Map { + return new Map( + [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map( + (m) => [m.key, m.name], + ), + ); +} + +/** + * The values menu as the page holds it: every series the record has, the + * chosen ones kept even when they have nothing yet, in `rank`'s order and + * then the record's; then the record's lab markers by group. It counts and + * toggles the lines it can name, so a stored marker the record no longer + * has drops out with the next change. + */ +export function TimelineValuesMenu({ + timeline, + values, + allowed, + rank, + labsOn, + label, + color, + onChange, + iconOnly, +}: { + timeline: TimelineResponse | undefined; + values: readonly string[]; + allowed: (key: string) => boolean; + rank: readonly string[]; + labsOn: boolean; + label: (key: string) => string; + color: (key: string) => string; + onChange: (next: string[]) => void; + iconOnly: boolean; +}) { + const { locale } = useTranslations(); + const options = [ + ...new Set([ + ...(timeline?.availableSeries ?? []).filter(allowed), + ...values.filter((key) => timelineLabMarkerId(key) === null), + ]), + ].sort((a, b) => rank.indexOf(a) - rank.indexOf(b)); + const markers = new Map( + [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map( + (m) => [m.key, m], + ), + ); + const labGroups = labsOn + ? labOptionGroups([...markers.values()], resolveIntlLocale(locale)) + : []; + return ( + timelineLabMarkerId(key) === null || markers.has(key), + )} + label={label} + color={color} + onChange={onChange} + iconOnly={iconOnly} + /> + ); +} diff --git a/src/components/timeline/timeline-tables.tsx b/src/components/timeline/timeline-tables.tsx new file mode 100644 index 000000000..93bbecce8 --- /dev/null +++ b/src/components/timeline/timeline-tables.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * The timeline's chart as tables, for a screen reader (v1.42, #613): the + * lane entries with their dates, and the value lines' means per bucket. + * Both are visually hidden; the phone chronicle is the visible list form. + * The lab lines' table sits with the lab lines (`lab-lines.tsx`). + */ +import type { + TimelineBucket, + TimelineLaneKey, + TimelineResponse, +} from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +import { itemLine, useItemWords } from "./item-words"; +import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; +import { medicationListItems } from "./medication-rows"; +import { formatSeriesValue, type SeriesValueFormat } from "./series-format"; +import { + bucketAfter, + bucketStart, + dayNumber, + formatAtPrecision, + formatBucket, +} from "./timeline-dates"; +import { isSpan } from "./timeline-geometry"; + +/** + * The chart as a table, for a screen reader: every entry with its lane and + * dates. Visually hidden; the phone chronicle is the visible list form. + */ +export function TimelineTable({ + timeline, + hiddenLanes, + intl, +}: { + timeline: TimelineResponse; + hiddenLanes: ReadonlySet; + intl: string; +}) { + const { t } = useTranslations(); + const words = useItemWords(); + const rows = timeline.lanes + .filter((lane) => !hiddenLanes.has(lane.key)) + .flatMap((lane) => + (lane.key === "medications" + ? medicationListItems(lane.items) + : lane.items + ).map((item) => ({ lane: lane.key, item })), + ) + .sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start)); + return ( + // The table is hidden by a wrapper, never by `sr-only` on the table + // itself: `overflow: hidden` does not apply to a table box, so a + // visually hidden table still laid out its rows at full height under + // the page and made `
` scroll past its last card into nothing. +
+ + + + + + + + + + + + {rows.map(({ lane, item }) => ( + + + + + + + ))} + +
{t("timeline.table.caption")}
{t("timeline.table.lane")}{t("timeline.table.entry")}{t("timeline.table.start")}{t("timeline.table.end")}
{t(TIMELINE_LANE_LABEL_KEY[lane])}{itemLine(item, words, t("timeline.startUnknown"))}{formatAtPrecision(item.start, item.precision, intl)} + {!item.open && isSpan(item) && item.end + ? formatAtPrecision(item.end, item.precision, intl) + : ""} +
+
+ ); +} + +/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */ +export function bucketText( + start: string, + bucket: TimelineBucket, + intl: string, + t: ReturnType["t"], +): string { + const label = formatBucket(start, bucket, intl); + return typeof label === "string" + ? label + : t("timeline.selection.range", label); +} + +/** + * The value lines as a table, for a screen reader: one row per bucket from + * the first to the last with a reading in the window, one column per + * series. A bucket without a reading says so; nothing is filled in. + */ +export function SeriesTable({ + timeline, + window, + seriesLabel, + fmt, + intl, +}: { + timeline: TimelineResponse; + window: { from: string; to: string }; + seriesLabel: (key: string) => string; + fmt: SeriesValueFormat; + intl: string; +}) { + const { t, tCount } = useTranslations(); + const { bucket, series } = timeline; + const inWindow = (start: string) => + start <= window.to && bucketAfter(start, bucket) > window.from; + const starts = series + .flatMap((s) => s.points.map((p) => p.t)) + .filter(inWindow) + .sort(); + if (starts.length === 0) return null; + const rows: string[] = []; + for ( + let start = bucketStart(starts[0], bucket); + start <= starts[starts.length - 1]; + start = bucketAfter(start, bucket) + ) { + rows.push(start); + } + const byKey = new Map( + series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]), + ); + return ( + // The table is hidden by a wrapper, never by `sr-only` on the table + // itself: `overflow: hidden` does not apply to a table box, so a + // visually hidden table still laid out its rows at full height under + // the page and made `
` scroll past its last card into nothing. +
+ + + + + + {series.map((s) => ( + + ))} + + + + {rows.reverse().map((start) => ( + + + {series.map((s) => { + const point = byKey.get(s.key)?.get(start); + return ( + + ); + })} + + ))} + +
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} + {seriesLabel(s.key)} +
{bucketText(start, bucket, intl, t)} + {point + ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` + : t("timeline.values.noValue")} +
+
+ ); +} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index ac8a9c97b..229f4da63 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -14,6 +14,10 @@ * and Back closes it. An incoming `?day=` selects that day and zooms to * three months, so a day's "In the timeline" lands on itself. * + * Lab markers the record has values for are offered beside the measurements + * (#1299): a chosen marker is stored and requested like any other line, as + * `lab:`, and drawn as its single readings, never as means. + * * At 768 px and up the chart; below it the chronicle, which is also the * list form of the chart. The readiness card sits on top while a lane is * thin and the person has not closed it; empty lanes are never drawn. @@ -35,8 +39,8 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { DAY_QUERY_PARAM, TIMELINE_ZOOMS, + timelineLabMarkerId, type LifeEventDTO, - type TimelineBucket, type TimelineLaneKey, type TimelineResponse, type TimelineZoom, @@ -49,9 +53,10 @@ import type { ModuleKey } from "@/lib/modules/registry"; import type { ChronicleGrouping } from "./chronicle-model"; import { TIMELINE_BLOOD_PRESSURE_SERIES_KEY, - TIMELINE_LEGEND_MEAN_KEY, TIMELINE_ZOOM_LABEL_KEY, } from "./label-keys"; +import { LabExcludedNote } from "./lab-lines"; +import { TimelineLegend } from "./timeline-legend"; import { LifeEventSheet } from "./life-event-sheet"; import { ReadinessCard } from "./readiness-card"; import { @@ -78,7 +83,11 @@ import { type TimelineRange, } from "./timeline-url"; import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry"; -import { LayersMenu, ValueSeriesMenu } from "./timeline-menus"; +import { + LayersMenu, + TimelineValuesMenu, + labMarkerNames, +} from "./timeline-menus"; import { isBoolean, isStringArray, @@ -140,13 +149,56 @@ function timelineIsEmpty(data: TimelineResponse): boolean { return ( data.lanes.every((l) => l.items.length === 0) && data.standing.length === 0 && - data.series.every((s) => s.points.length === 0) + data.series.every((s) => s.points.length === 0) && + data.labSeries.every((s) => s.points.length === 0) ); } const VALUES_KEY = "healthlog.timeline.values"; const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes"; +/** + * The value lines asked for: the stored choice, minus anything unknown and + * minus a line whose module is off, and one colour per chosen line. + */ +function useValueLines( + modules: Partial> | undefined, +) { + const [storedValues, setStoredValues] = useDevicePreference( + VALUES_KEY, + DEFAULT_VALUES, + isStringArray, + ); + // What is asked for: the stored choice, minus anything unknown and minus + // a line whose module is off. The server narrows it further to what the + // caller may see. + const allowed = useCallback( + (key: string) => { + // A lab line belongs to the labs module; whether the record has the + // marker is the server's to say. + if (timelineLabMarkerId(key) !== null) return modules?.labs !== false; + if (!KNOWN_SERIES.has(key)) return false; + const owner: ModuleKey | null | undefined = + VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE]; + return !owner || modules?.[owner] !== false; + }, + [modules], + ); + const values = useMemo( + () => storedValues.filter(allowed), + [storedValues, allowed], + ); + // One colour per chosen line, shared by the chart, the selection bar, the + // menu and the phone chronicle; contested colours go by the menu's order. + const seriesColors = useMemo( + () => assignSeriesColors(values, SERIES_RANK), + [values], + ); + const seriesColor = (key: string) => + seriesColors.get(key) ?? SERIES_PALETTE[0]; + return { values, allowed, setStoredValues, seriesColor }; +} + export function TimelineView() { const { t } = useTranslations(); const { user } = useAuth(); @@ -183,11 +235,6 @@ export function TimelineView() { const [lifeEventOpen, setLifeEventOpen] = useState(false); const [editing, setEditing] = useState(null); - const [storedValues, setStoredValues] = useDevicePreference( - VALUES_KEY, - DEFAULT_VALUES, - isStringArray, - ); const [hiddenList, setHiddenList] = useDevicePreference( HIDDEN_LANES_KEY, [], @@ -200,31 +247,9 @@ export function TimelineView() { ); const modules = user?.modules; - // What is asked for: the stored choice, minus anything unknown and minus - // a line whose module is off. The server narrows it further to what the - // caller may see. - const allowed = useCallback( - (key: string) => { - if (!KNOWN_SERIES.has(key)) return false; - const owner: ModuleKey | null | undefined = - VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE]; - return !owner || modules?.[owner] !== false; - }, - [modules], - ); - const values = useMemo( - () => storedValues.filter(allowed), - [storedValues, allowed], - ); - // One colour per chosen line, shared by the chart, the selection bar, the - // menu and the phone chronicle; contested colours go by the menu's order. - const seriesColors = useMemo( - () => assignSeriesColors(values, SERIES_RANK), - [values], - ); + const { values, allowed, setStoredValues, seriesColor } = + useValueLines(modules); const seriesFormat = useSeriesValueFormat(); - const seriesColor = (key: string) => - seriesColors.get(key) ?? SERIES_PALETTE[0]; const hidden = useMemo( () => new Set(hiddenList as TimelineLaneKey[]), [hiddenList], @@ -334,14 +359,17 @@ export function TimelineView() { setLifeEventOpen(true); } + const labNames = labMarkerNames(data); // Blood pressure reads as its own name here; the list labels ("Sys", - // "Dia") lean on a column header the chart does not have. + // "Dia") lean on a column header the chart does not have. A lab marker + // reads by its own name, as on the labs list. const seriesLabel = (key: string) => key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA" ? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key]) : key === "MOOD" ? t("timeline.readiness.lanes.mood") - : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key); + : (labNames.get(key) ?? + t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key)); const shownWindow = data ? windowFor( @@ -367,12 +395,19 @@ export function TimelineView() { today, data?.range.dataFrom ?? null, ); - // The selector offers every series the record has, the chosen ones kept - // even when they have nothing yet, in the selector's order and then the - // record's. - const valueOptions = [ - ...new Set([...(data?.availableSeries ?? []).filter(allowed), ...values]), - ].sort((a, b) => SERIES_RANK.indexOf(a) - SERIES_RANK.indexOf(b)); + const valuesMenu = (iconOnly: boolean) => ( + + ); const presentLanes = (data?.lanes ?? []) .filter((l) => l.items.length > 0) .map((l) => l.key) @@ -532,13 +567,7 @@ export function TimelineView() { onApply={(next) => navigate("range", next)} />
- + {valuesMenu(false)} {layers(false)}
@@ -571,6 +600,8 @@ export function TimelineView() { 0} + labSeries={data.labSeries} + label={seriesLabel} /> @@ -593,6 +624,7 @@ export function TimelineView() { slot="timeline-grouping" />
+ {valuesMenu(true)}