From f13aa684f9fe6773fc47332aec1e463d180c7f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH 1/9] fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-context adopt runs read-modify-write inside one transaction, the admin shell renders nothing until the role is confirmed, and the json-body helper merges Headers instances correctly. The compose image reference accepts a pinned digest through an env var, and a promote script resolves the published digest, verifies its signature and hands the operator the exact pinning call — the documented path away from a moving latest tag. --- docker-compose.yml | 27 +- docs/ops/deploy.md | 73 +++++- scripts/promote-digest.ts | 242 ++++++++++++++++++ .../about-me/adopt/__tests__/route.test.ts | 160 ++++++++++++ src/app/api/coach/about-me/adopt/route.ts | 106 +++++--- .../admin/__tests__/admin-shell.test.tsx | 30 +++ .../__tests__/invite-tokens-section.test.tsx | 203 +++++++++++++++ src/components/admin/admin-shell.tsx | 9 + .../admin/invite-tokens-section.tsx | 32 ++- src/lib/ai/coach/about-me.ts | 7 +- src/lib/api/__tests__/api-fetch.test.ts | 56 +++- src/lib/api/api-fetch.ts | 10 +- 12 files changed, 876 insertions(+), 79 deletions(-) create mode 100644 scripts/promote-digest.ts create mode 100644 src/app/api/coach/about-me/adopt/__tests__/route.test.ts create mode 100644 src/components/admin/__tests__/invite-tokens-section.test.tsx diff --git a/docker-compose.yml b/docker-compose.yml index b173c7de4..67bc2ddbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,15 +4,22 @@ services: # For horizontal scaling switch to the web/worker split below # (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker). app: - # Tracks the GHCR `:latest` multi-arch image. `pull_policy: always` - # is the load-bearing line — without it, Docker keeps a stale - # `:latest` digest cached on the host and skips the registry round- - # trip on `compose up`, which is what kept Coolify re-deploying the - # previous image after every tag push. With pull_policy=always the - # compose-up always re-checks the registry digest and pulls when - # GHCR has a fresher manifest. The small bandwidth cost on every - # `compose up` is the price of digest-correctness. - image: ghcr.io/mbombeck/healthlog:latest + # Tracks the GHCR `:latest` multi-arch image by default. + # `pull_policy: always` is the load-bearing line — without it, + # Docker keeps a stale `:latest` digest cached on the host and + # skips the registry round-trip on `compose up`, which is what kept + # Coolify re-deploying the previous image after every tag push. + # With pull_policy=always the compose-up always re-checks the + # registry digest and pulls when GHCR has a fresher manifest. + # + # HEALTHLOG_IMAGE_REF pins the image deterministically without + # editing this file: set it in the compose `.env` (or the Coolify + # env vars) to `:vX.Y.Z` for a tag or `@sha256:` for an + # immutable digest reference (see docs/ops/deploy.md, "Digest + # pinning"; scripts/promote-digest.ts resolves + verifies the + # digest after a release). Unset, everything behaves exactly as + # before — `:latest` plus pull_policy. + image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest} pull_policy: always container_name: healthlog-app # The app runs as the unprivileged `nextjs` user and never needs a @@ -108,7 +115,7 @@ services: # See docs/self-hosting/scaling.md for the full guide. # # app-worker: - # image: ghcr.io/mbombeck/healthlog:latest + # image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest} # build: # context: . # dockerfile: Dockerfile diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index ad890d7d9..7e2065897 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -121,19 +121,72 @@ image's only immutable identity, and it is what the signature actually covers. For any deploy where supply-chain integrity matters more than convenience: -1. Resolve and verify the digest once: +The compose file carries the hook for this: the `app` service image is +`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the +day-to-day `:latest` + `pull_policy: always` flow stays the documented +default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose +`.env` (or the deployment platform's env vars) to switch the reference +without touching the file: - ```sh - cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \ - | jq -r '.[0].critical.image."docker-manifest-digest"' - ``` +- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable). +- `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest. -2. Pin the compose `image:` (or the Coolify image reference) to - `ghcr.io/mbombeck/healthlog@sha256:` instead of the tag. +### Promote a release to a pinned digest -The day-to-day `:latest` + `pull_policy: always` flow stays the -documented default for self-hosters; digest pinning is the recommended -hardening for operators with a threat model that includes the registry. +`scripts/promote-digest.ts` automates the whole promotion after +`docker-publish.yml` has pushed a tag: + +```sh +pnpm dlx tsx scripts/promote-digest.ts v1.16.5 +``` + +It resolves the multi-arch index digest from the GHCR registry API +(anonymous pull token, no docker daemon), verifies the keyless cosign +signature against the workflow identity (fails closed — no cosign, no +promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…` +value. + +For a Coolify-managed deployment it can also apply the pin. Two facts +about Coolify shape the mechanism (verified against the Coolify +OpenAPI spec and a live instance): + +- For a git-based "Docker Compose" application, Coolify re-reads the + compose file from the repository checkout on every deploy. + `docker_compose_raw` is its stored copy of that file, and the + application-update API (`PATCH /api/v1/applications/{uuid}`) does not + accept the field — editing the stored compose is neither possible + via API nor durable. +- Application **env vars** are durable, exposed via the documented + `/api/v1/applications/{uuid}/envs` endpoints, and interpolated into + the compose at deploy time. + +So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With +`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the +environment the script creates/updates the var itself (add +`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it +prints the equivalent curl commands: + +```sh +curl -sS -X PATCH "https:///api/v1/applications//envs" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:"}' + +curl -sS "https:///api/v1/deploy?uuid=" \ + -H "Authorization: Bearer " +``` + +After the deploy settles, confirm `/api/version` reports the released +`version` + `buildSha` on every target (see above). + +**Rollback:** re-run the script with the previous tag (or PATCH the env +var back to the previous digest / to empty for `:latest`) and redeploy. +Digest pinning trades the automatic `:latest` pickup for determinism — +a pinned instance only moves when the var moves, so the promotion step +becomes part of the release routine, not an optional extra. + +Digest pinning is the recommended hardening for operators with a +threat model that includes the registry; `:latest` remains the +low-friction default. ## Deploy-status webhook: replay protection diff --git a/scripts/promote-digest.ts b/scripts/promote-digest.ts new file mode 100644 index 000000000..4426a958d --- /dev/null +++ b/scripts/promote-digest.ts @@ -0,0 +1,242 @@ +/** + * promote-digest.ts — pin a released image to its immutable digest. + * + * After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this + * script turns the mutable tag into a deterministic deploy reference: + * + * 1. Resolves the multi-arch index digest for the tag straight from + * the GHCR registry API (anonymous pull token — the image is + * public; no docker daemon needed). + * 2. Verifies the keyless cosign signature the publish workflow + * attached to that digest (GitHub Actions OIDC identity). Fails + * closed: no cosign binary or a failed verification aborts the + * promotion. + * 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose + * file interpolates into `image:` — and, when a Coolify API token + * is present in the environment, writes it to the application's + * env vars via the documented `/envs` endpoint. Without a token + * it prints the exact curl commands instead of guessing. + * + * Why an env var and not the compose file itself: for a git-based + * "Docker Compose" application Coolify re-reads the compose file from + * the repository checkout on every deploy (`docker_compose_raw` is its + * stored copy of that file and is not part of the application-update + * API surface), so editing the stored compose is not durable. The + * application env vars ARE durable and are interpolated into the + * compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}` + * resolves to the pinned digest when the var is set and to `:latest` + * when it is not. + * + * Usage (one-shot, not a recurring job): + * pnpm dlx tsx scripts/promote-digest.ts v1.16.5 + * + * Env (all optional — without them the script prints commands only): + * COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens) + * COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com + * COOLIFY_APP_UUID UUID of the HealthLog application resource + * PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update + * + * See docs/ops/deploy.md ("Digest pinning") for the full runbook, + * including rollback (re-point the var at the previous digest). + */ + +import { execFileSync } from "node:child_process"; + +const IMAGE_REPO = "mbombeck/healthlog"; +const IMAGE = `ghcr.io/${IMAGE_REPO}`; +const ENV_KEY = "HEALTHLOG_IMAGE_REF"; +const COSIGN_ISSUER = "https://token.actions.githubusercontent.com"; +const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/"; + +// The OCI index media types a multi-arch publish can produce. GHCR +// answers the manifest request with the digest of whichever it stores; +// accepting both keeps the script working across buildx versions. +const INDEX_ACCEPT = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", +].join(", "); + +function fail(message: string): never { + console.error(`\nERROR: ${message}`); + process.exit(1); +} + +async function resolveDigest(tag: string): Promise { + // Anonymous pull token — the package is public, so no credentials. + const tokenRes = await fetch( + `https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`, + ); + if (!tokenRes.ok) { + fail(`GHCR token request failed: HTTP ${tokenRes.status}`); + } + const { token } = (await tokenRes.json()) as { token?: string }; + if (!token) fail("GHCR token response carried no token"); + + // HEAD is enough — the digest rides the Docker-Content-Digest header. + const manifestRes = await fetch( + `https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`, + { + method: "HEAD", + headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT }, + }, + ); + if (!manifestRes.ok) { + fail( + `manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`, + ); + } + const digest = manifestRes.headers.get("docker-content-digest"); + if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) { + fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`); + } + return digest; +} + +function verifyCosign(digest: string): void { + const ref = `${IMAGE}@${digest}`; + try { + execFileSync( + "cosign", + [ + "verify", + "--certificate-oidc-issuer", + COSIGN_ISSUER, + "--certificate-identity-regexp", + COSIGN_IDENTITY_RE, + ref, + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + } catch (err) { + const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; + if (e.code === "ENOENT") { + fail( + "cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.", + ); + } + fail( + `cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`, + ); + } + console.log(`cosign signature verified for ${ref}`); +} + +interface CoolifyEnvRow { + uuid: string; + key: string; + value?: string; +} + +async function coolifyRequest( + base: string, + token: string, + path: string, + init?: RequestInit, +): Promise { + return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }); +} + +async function updateCoolifyEnv( + base: string, + token: string, + appUuid: string, + value: string, +): Promise { + const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`); + if (!list.ok) { + fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`); + } + const rows = (await list.json()) as CoolifyEnvRow[]; + const existing = Array.isArray(rows) + ? rows.find((r) => r.key === ENV_KEY) + : undefined; + + const method = existing ? "PATCH" : "POST"; + const res = await coolifyRequest( + base, + token, + `/applications/${appUuid}/envs`, + { method, body: JSON.stringify({ key: ENV_KEY, value }) }, + ); + if (!res.ok) { + fail(`Coolify env ${method} failed: HTTP ${res.status}`); + } + console.log( + `Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`, + ); +} + +async function triggerDeploy( + base: string, + token: string, + appUuid: string, +): Promise { + const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`); + if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`); + console.log("Deploy triggered. Verify /api/version after it settles."); +} + +function printManualCommands(value: string): void { + console.log(` +No COOLIFY_API_TOKEN in the environment — manual path (fill in host, +token and application UUID; the UUID is in the Coolify URL of the app): + + # set / update the pin (POST creates, PATCH updates an existing key) + curl -sS -X PATCH "https:///api/v1/applications//envs" \\ + -H "Authorization: Bearer " -H "Content-Type: application/json" \\ + -d '{"key":"${ENV_KEY}","value":"${value}"}' + + # redeploy with the pinned digest + curl -sS "https:///api/v1/deploy?uuid=" \\ + -H "Authorization: Bearer " +`); +} + +async function main(): Promise { + const tag = process.argv[2]; + if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) { + fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z"); + } + + console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`); + const digest = await resolveDigest(tag); + console.log(`digest: ${digest}`); + + verifyCosign(digest); + + const value = `@${digest}`; + console.log(`\n${ENV_KEY}=${value}`); + + const token = process.env.COOLIFY_API_TOKEN; + const base = process.env.COOLIFY_API_URL; + const appUuid = process.env.COOLIFY_APP_UUID; + + if (token && base && appUuid) { + await updateCoolifyEnv(base, token, appUuid, value); + if (process.env.PROMOTE_DEPLOY === "1") { + await triggerDeploy(base, token, appUuid); + } else { + console.log( + "Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).", + ); + } + } else { + if (token || base || appUuid) { + console.log( + "Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.", + ); + } + printManualCommands(value); + } +} + +main().catch((err) => fail(err instanceof Error ? err.message : String(err))); diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts new file mode 100644 index 000000000..09e0fea66 --- /dev/null +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * POST /api/coach/about-me/adopt — locks the read-modify-write. + * + * The original handler read the self-context through the global client, + * computed the appended value, then upserted: two concurrent adoptions + * could read the same base value and one append silently lost. The + * suite pins the transactional shape — empty upsert → `FOR UPDATE` row + * lock → read through the SAME `tx` client → single-column update — + * so a refactor can't quietly fall back to the racy read. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +const tx = { + userHealthProfile: { + upsert: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + $queryRaw: vi.fn(), +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => Buffer.from(s), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextForUser: vi.fn(), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + getClientIp: () => "127.0.0.1", + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async (req: Request) => ({ data: await req.json(), error: null }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; + +const transaction = prisma.$transaction as ReturnType; +const getCtx = getSelfContextForUser as ReturnType; + +function adoptRequest(body: { question: string; answer: string }): Request { + return new Request("http://localhost/api/coach/about-me/adopt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +type Envelope = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as (req: Request) => Promise; + +const emptyCtx = { + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, +}; + +describe("POST /api/coach/about-me/adopt", () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" }); + tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() }); + tx.$queryRaw.mockResolvedValue([{ id: "p1" }]); + }); + + it("runs the read-modify-write inside one transaction with a row lock", async () => { + getCtx.mockResolvedValue(emptyCtx); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ adopted: true, field: "coachFocus" }); + expect(transaction).toHaveBeenCalledTimes(1); + // Row pinned, then locked FOR UPDATE before the read. + expect(tx.userHealthProfile.upsert).toHaveBeenCalledWith({ + where: { userId: "u1" }, + create: { userId: "u1" }, + update: {}, + select: { id: true }, + }); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + // The read goes through the SAME tx client, not the global one. + expect(getCtx).toHaveBeenCalledWith("u1", tx); + // Exactly one encrypted column written. + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]); + }); + + it("dedupes inside the locked section without writing", async () => { + getCtx.mockResolvedValue({ ...emptyCtx, coachFocus: "Evening walks" }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.data).toEqual({ + adopted: false, + field: "coachFocus", + reason: "duplicate", + }); + expect(tx.userHealthProfile.update).not.toHaveBeenCalled(); + }); + + it("appends to an existing value read under the lock", async () => { + getCtx.mockResolvedValue({ ...emptyCtx, allergies: "pollen" }); + + const res = await post( + adoptRequest({ question: "Any allergies?", answer: "penicillin" }), + ); + + expect(res.data).toEqual({ adopted: true, field: "allergies" }); + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Buffer.from(data.allergiesEncrypted).toString()).toBe( + "pollen\npenicillin", + ); + }); + + it("422s when the overflow target aboutMe is full, still without a write", async () => { + getCtx.mockResolvedValue({ + ...emptyCtx, + coachFocus: "x".repeat(499), + aboutMe: "y".repeat(3999), + }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "z".repeat(10) }), + ); + + expect(res.status).toBe(422); + expect(tx.userHealthProfile.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index d158bb8b4..ee5463153 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -113,56 +113,82 @@ export const POST = apiHandler(async (req: Request) => { } const { question, answer } = parsed.data; - const ctx = await getSelfContextForUser(user.id); - - let field: StructuredField | "aboutMe" = matchSelfContextField(question); const normAnswer = normalise(answer); - // Dedupe BEFORE any write: an answer already present in the target - // field or in the free-text aboutMe never stacks a second copy. - const targetExisting = ctx[field]; - if ( - (targetExisting && normalise(targetExisting).includes(normAnswer)) || - (ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer)) - ) { + // Read-modify-write under a row lock: two concurrent adoptions used + // to read the same base value and one append silently lost (or the + // dedupe missed the other's in-flight write). The empty upsert pins + // the row, `FOR UPDATE` serialises every adoption for this user, and + // the read happens through the same `tx` so it sees the locked state. + const outcome = await prisma.$transaction(async (tx) => { + await tx.userHealthProfile.upsert({ + where: { userId: user.id }, + create: { userId: user.id }, + update: {}, + select: { id: true }, + }); + await tx.$queryRaw` + SELECT id FROM user_health_profiles + WHERE user_id = ${user.id} + FOR UPDATE + `; + const ctx = await getSelfContextForUser(user.id, tx); + + let field: StructuredField | "aboutMe" = matchSelfContextField(question); + + // Dedupe BEFORE any write: an answer already present in the target + // field or in the free-text aboutMe never stacks a second copy. + const targetExisting = ctx[field]; + if ( + (targetExisting && normalise(targetExisting).includes(normAnswer)) || + (ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer)) + ) { + return { kind: "duplicate" as const, field }; + } + + // Append on its own line; a structured field at its cap overflows + // into the free-text aboutMe (4000 chars) rather than failing the + // adoption outright. + let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer; + if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) { + field = "aboutMe"; + nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer; + if (nextValue.length > ABOUT_ME_MAX_CHARS) { + return { kind: "full" as const, field }; + } + } + + // Field-by-field assembly (no mass assignment): exactly one + // encrypted column is written per adoption. + const column = ( + { + conditions: "conditionsEncrypted", + allergies: "allergiesEncrypted", + coachFocus: "coachFocusEncrypted", + aboutMe: "aboutMeEncrypted", + } as const + )[field]; + + await tx.userHealthProfile.update({ + where: { userId: user.id }, + data: { [column]: encryptToBytes(nextValue) }, + select: { updatedAt: true }, + }); + return { kind: "adopted" as const, field }; + }); + + const { field } = outcome; + if (outcome.kind === "duplicate") { annotate({ action: { name: "coach.about_me.adopt_deduped" }, meta: { field }, }); return apiSuccess({ adopted: false, field, reason: "duplicate" }); } - - // Append on its own line; a structured field at its cap overflows - // into the free-text aboutMe (4000 chars) rather than failing the - // adoption outright. - let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer; - if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) { - field = "aboutMe"; - nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer; - if (nextValue.length > ABOUT_ME_MAX_CHARS) { - return apiError("Self-context is full", 422); - } + if (outcome.kind === "full") { + return apiError("Self-context is full", 422); } - // Field-by-field assembly (no mass assignment): exactly one encrypted - // column is written per adoption. - const column = ( - { - conditions: "conditionsEncrypted", - allergies: "allergiesEncrypted", - coachFocus: "coachFocusEncrypted", - aboutMe: "aboutMeEncrypted", - } as const - )[field]; - const payload = { [column]: encryptToBytes(nextValue) }; - - await prisma.userHealthProfile.upsert({ - where: { userId: user.id }, - create: { userId: user.id, ...payload }, - update: payload, - select: { updatedAt: true }, - }); - await auditLog("coach.about_me.adopted", { userId: user.id, ipAddress: getClientIp(req), diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index fbbd28d3d..166d35e6f 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -21,6 +21,17 @@ vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, })); +// The shell gates its own frame on the confirmed ADMIN role — a +// non-admin (or a still-loading auth state) must not see the section +// nav while AuthShell's redirect effect is catching up. +const mockUserRef = { value: { role: "ADMIN", username: "op" } as { + role: string; + username: string; +} | null }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: mockUserRef.value }), +})); + import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; @@ -31,8 +42,11 @@ import { ADMIN_SECTIONS, AdminShell } from "../admin-shell"; function renderShell(props: { active?: (typeof ADMIN_SECTIONS)[number]["slug"]; pathname?: string; + user?: { role: string; username: string } | null; }) { mockPathnameRef.value = props.pathname ?? "/admin/system-status"; + mockUserRef.value = + props.user === undefined ? { role: "ADMIN", username: "op" } : props.user; return renderToStaticMarkup( @@ -133,6 +147,22 @@ describe("", () => { ).toBe(true); }); + it("renders neither frame nor children until the role is confirmed ADMIN", () => { + // Non-admin: no section nav, no children — the frame must not + // flash while AuthShell's redirect effect moves the user away. + const asUser = renderShell({ + active: "system-status", + user: { role: "USER", username: "u" }, + }); + expect(asUser).toBe(""); + // Auth still in flight (user === null): same blank render. + const loading = renderShell({ active: "system-status", user: null }); + expect(loading).toBe(""); + // Admin: frame + children render. + const asAdmin = renderShell({ active: "system-status" }); + expect(asAdmin).toContain("section body"); + }); + it("does NOT mark any section active on `/admin` overview", () => { const html = renderShell({ pathname: "/admin" }); // Overview-link only — no section gets aria-current. diff --git a/src/components/admin/__tests__/invite-tokens-section.test.tsx b/src/components/admin/__tests__/invite-tokens-section.test.tsx new file mode 100644 index 000000000..3b76578ed --- /dev/null +++ b/src/components/admin/__tests__/invite-tokens-section.test.tsx @@ -0,0 +1,203 @@ +/** + * v1.16.5 — admin invite section: multi-use surface contracts. + * + * SSR smoke tests per project convention (`renderToStaticMarkup` + + * seeded QueryClient; no `@testing-library/react`). Pinned: + * + * 1. The table renders `uses / maxUses` for a multi-use invite. + * 2. uses == maxUses surfaces the "Aufgebraucht" (exhausted) chip. + * 3. Multiple redemptions render the "+N weitere" popover trigger; + * a single redemption renders inline without one. + * 4. `clampMaxUses` pins the create-dialog field to the 1–50 range + * (the same clamp the submit path sends). + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + InviteTokensSection, + clampMaxUses, +} from "@/components/admin/invite-tokens-section"; +import { queryKeys } from "@/lib/query-keys"; + +const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString(); +const PAST = new Date(Date.now() - 86_400_000).toISOString(); + +interface SeedInvite { + id: string; + createdAt: string; + expiresAt: string; + usedAt: string | null; + revokedAt: string | null; + uses: number; + maxUses: number; + creator: { id: string; username: string } | null; + consumer: { id: string; username: string } | null; + redemptions: Array<{ + id: string; + redeemedAt: string; + user: { id: string; username: string; email: string | null } | null; + }>; +} + +function makeInvite(overrides: Partial): SeedInvite { + return { + id: "inv-1", + createdAt: PAST, + expiresAt: FUTURE, + usedAt: null, + revokedAt: null, + uses: 0, + maxUses: 1, + creator: { id: "admin-1", username: "operator" }, + consumer: null, + redemptions: [], + ...overrides, + }; +} + +function render(invites: SeedInvite[]): string { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); + client.setQueryData(queryKeys.adminInvites(), invites); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("InviteTokensSection (multi-use)", () => { + it("renders uses / maxUses for a partially used multi-use invite", () => { + const html = render([ + makeInvite({ + uses: 2, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + ], + }), + ]); + expect(html).toContain("/ 5"); + expect(html).toContain("invite-status-active"); + }); + + it("surfaces the exhausted chip when uses reaches maxUses", () => { + const html = render([ + makeInvite({ + uses: 3, + maxUses: 3, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + { + id: "r3", + redeemedAt: PAST, + user: { id: "u3", username: "gamma", email: null }, + }, + ], + }), + ]); + expect(html).toContain("invite-status-exhausted"); + expect(html).toContain("Aufgebraucht"); + }); + + it("multiple redemptions render the popover trigger with the +N summary", () => { + const html = render([ + makeInvite({ + uses: 3, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + { + id: "r3", + redeemedAt: PAST, + user: { id: "u3", username: "gamma", email: null }, + }, + ], + }), + ]); + expect(html).toContain("admin-invite-redemptions-trigger"); + // de: "{username} + {count} weitere" — newest redemption leads. + expect(html).toContain("alpha + 2 weitere"); + }); + + it("a single redemption renders inline without a popover trigger", () => { + const html = render([ + makeInvite({ + uses: 1, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + ], + }), + ]); + expect(html).toContain("alpha"); + expect(html).not.toContain("admin-invite-redemptions-trigger"); + }); +}); + +describe("clampMaxUses", () => { + it("passes the 1–50 range through", () => { + expect(clampMaxUses("1")).toBe(1); + expect(clampMaxUses("25")).toBe(25); + expect(clampMaxUses("50")).toBe(50); + }); + + it("clamps above the cap to 50", () => { + expect(clampMaxUses("51")).toBe(50); + expect(clampMaxUses("9999")).toBe(50); + }); + + it("falls back to 1 for zero, negatives, and garbage", () => { + expect(clampMaxUses("0")).toBe(1); + expect(clampMaxUses("-3")).toBe(1); + expect(clampMaxUses("")).toBe(1); + expect(clampMaxUses("abc")).toBe(1); + }); + + it("truncates decimals to the integer part", () => { + expect(clampMaxUses("2.9")).toBe(2); + }); +}); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index a8b34b9a4..9a583fedb 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -39,6 +39,7 @@ import { import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; @@ -172,6 +173,7 @@ function deriveActiveSlug( export function AdminShell({ active, children }: AdminShellProps) { const pathname = usePathname(); const { t } = useTranslations(); + const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); // v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so @@ -199,6 +201,13 @@ export function AdminShell({ active, children }: AdminShellProps) { }); }, [activeSlug]); + // The section bodies are already role-gated, but the shell frame + // itself (the full section nav in two layouts) used to paint for a + // non-admin during the frames between auth resolving and AuthShell's + // redirect effect replacing the route. Render nothing — frame AND + // children — until the role is confirmed ADMIN. + if (!user || user.role !== "ADMIN") return null; + // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 67b27dbfe..0759631da 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -123,6 +123,18 @@ export function deriveInviteStatus( const TTL_CHOICES = [7, 14, 30] as const; const MAX_USES_CAP = 50; +/** + * Clamp the raw max-uses field to the mintable range (integer 1–50). + * Shared by the submit path and the blur echo so the admin always sees + * exactly what the POST will carry. Exported for unit tests. + */ +export function clampMaxUses(raw: string): number { + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 1 + ? Math.min(parsed, MAX_USES_CAP) + : 1; +} + /** Whole days (ceil) from `now` until `iso`; negative when past. */ function daysUntil(iso: string, now: Date): number { const ms = new Date(iso).getTime() - now.getTime(); @@ -253,12 +265,7 @@ export function InviteTokensSection() { } function submitCreate() { - const parsedMax = Number.parseInt(maxUses, 10); - const safeMax = - Number.isFinite(parsedMax) && parsedMax >= 1 - ? Math.min(parsedMax, MAX_USES_CAP) - : 1; - create.mutate({ expiresInDays: ttlDays, maxUses: safeMax }); + create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) }); } const now = new Date(); @@ -591,8 +598,14 @@ export function InviteTokensSection() { inputMode="numeric" min={1} max={MAX_USES_CAP} + step={1} value={maxUses} onChange={(e) => setMaxUses(e.target.value)} + onBlur={() => { + // Echo the submit-time clamp (1–50, integer) into + // the field so the admin sees what will be minted. + setMaxUses(String(clampMaxUses(maxUses))); + }} className="w-24 tabular-nums" data-testid="admin-invites-max-uses" /> @@ -753,7 +766,12 @@ function RedemptionsCell({ count: invite.redemptions.length, })}

-
    + {/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the + list height so the popover never outgrows the viewport. */} +
      {invite.redemptions.map((entry) => (
    • diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index 33430c669..d87a39225 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -61,12 +61,17 @@ function decryptOrNull(payload: Uint8Array | null): string | null { /** * Load + decrypt the caller's structured self-context. Every field is * independently fail-closed; a row that never existed yields all-null. + * + * `db` lets callers inside a transaction (the adopt endpoint's locked + * read-modify-write) read through their `tx` client; default is the + * global client. */ export async function getSelfContextForUser( userId: string, + db: Pick = prisma, ): Promise { try { - const row = await prisma.userHealthProfile.findUnique({ + const row = await db.userHealthProfile.findUnique({ where: { userId }, select: { aboutMeEncrypted: true, diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 6cdcf430b..f3e822aa4 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -192,11 +192,13 @@ describe("verb helpers", () => { await helper("/api/foo", { a: 1 }); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ a: 1 }), - }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/foo"); + expect(init.method).toBe(method); + expect(init.body).toBe(JSON.stringify({ a: 1 })); + expect(new Headers(init.headers).get("Content-Type")).toBe( + "application/json", + ); }); it("omits body and Content-Type when no body is given", async () => { @@ -212,10 +214,44 @@ describe("verb helpers", () => { await apiPost("/api/foo", { a: 1 }, { headers: { "X-Req": "1" } }); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { - method: "POST", - headers: { "Content-Type": "application/json", "X-Req": "1" }, - body: JSON.stringify({ a: 1 }), - }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("X-Req")).toBe("1"); + expect(init.body).toBe(JSON.stringify({ a: 1 })); + }); + + it("keeps caller headers passed as a Headers instance (no spread loss)", async () => { + // A `Headers` instance has no enumerable own properties, so the + // previous `{ ...init.headers }` merge silently dropped every + // caller header for this `HeadersInit` shape. + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiPost( + "/api/foo", + { a: 1 }, + { headers: new Headers({ "X-Req": "1", "Idempotency-Key": "k1" }) }, + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get("X-Req")).toBe("1"); + expect(headers.get("Idempotency-Key")).toBe("k1"); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("respects a caller-supplied Content-Type override", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiPost( + "/api/foo", + { a: 1 }, + { headers: new Headers({ "Content-Type": "application/json-patch+json" }) }, + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get("Content-Type")).toBe( + "application/json-patch+json", + ); }); }); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 87b4f250e..6b2afcaf2 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -141,7 +141,15 @@ function withJsonBody( ): RequestInit { const merged: RequestInit = { ...init, method }; if (body !== undefined) { - merged.headers = { "Content-Type": "application/json", ...init?.headers }; + // `HeadersInit` is a union — a plain object spreads fine, but a + // `Headers` instance (or entries array) has no enumerable own + // properties, so `{ ...init.headers }` silently dropped every + // caller header. `new Headers(...)` normalises all three shapes. + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + merged.headers = headers; merged.body = JSON.stringify(body); } return merged; From a62e00cfcd299ed0a83588378f163633c7eb0254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH 2/9] feat(coach): a guided dialog for follow-ups and nudges that know their owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open follow-up questions now run as a guided in-chat sequence — a quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending and a closing summary says what was taken over. Proactive nudges learn three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — address the focus the user asked to watch, and gain per-group toggles and a frequency preference. --- e2e/coach-guided-questions.spec.ts | 151 +++++ messages/de.json | 74 ++- messages/en.json | 74 ++- messages/es.json | 74 ++- messages/fr.json | 74 ++- messages/it.json | 74 ++- messages/pl.json | 74 ++- .../__tests__/route.test.ts | 22 +- .../__tests__/guided-dialog.test.tsx | 133 ++++ .../guided-questions-machine.test.ts | 274 ++++++++ .../__tests__/message-thread.test.tsx | 129 +++- .../coach-panel/coach-conversation.tsx | 199 ++++-- .../insights/coach-panel/coach-input.tsx | 9 +- .../coach-panel/guided-dialog-bubbles.tsx | 241 +++++++ .../coach-panel/guided-questions-card.tsx | 112 ++++ .../coach-panel/guided-questions-machine.ts | 244 ++++++++ .../insights/coach-panel/message-thread.tsx | 133 +++- .../coach-panel/self-context-adopt-offer.tsx | 20 +- .../coach-panel/self-context-chips.tsx | 123 ---- src/components/settings/coach-nudge-card.tsx | 155 ++++- src/lib/jobs/__tests__/coach-nudge.test.ts | 360 ++++++++++- src/lib/jobs/coach-nudge.ts | 591 ++++++++++++++---- .../__tests__/notification-prefs.test.ts | 120 +++- src/lib/validations/notification-prefs.ts | 54 ++ src/lib/validations/schedule-revision.ts | 62 ++ 25 files changed, 3199 insertions(+), 377 deletions(-) create mode 100644 e2e/coach-guided-questions.spec.ts create mode 100644 src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx create mode 100644 src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts create mode 100644 src/components/insights/coach-panel/guided-dialog-bubbles.tsx create mode 100644 src/components/insights/coach-panel/guided-questions-card.tsx create mode 100644 src/components/insights/coach-panel/guided-questions-machine.ts delete mode 100644 src/components/insights/coach-panel/self-context-chips.tsx create mode 100644 src/lib/validations/schedule-revision.ts diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts new file mode 100644 index 000000000..77e388876 --- /dev/null +++ b/e2e/coach-guided-questions.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Guided clarifying-questions flow on the full-page Coach route + * (v1.16.5 — V2 of the v1.16.0 composer chips). + * + * Contracts under test: + * + * 1. With pending self-context questions, the entry card + * (`data-slot="coach-guided-offer"`) renders above the composer + * and offers start / later / don't-ask-again. + * 2. Starting the flow mounts the first deterministic question + * bubble in the thread (`data-slot="coach-guided-question"`, + * `data-state="current"`) with the progress line and the + * skip / later / dismiss actions. + * 3. Skip advances to the next question without any server write. + * 4. "Later" mid-sequence ends the flow without a summary when + * nothing was answered; the entry card stays hidden for the + * session. + * 5. The entry-card "later" hides the offer without touching the + * thread. + * + * The questions API is mocked — the flow itself is purely client-side + * until an answer is sent, and no answer is sent here (the chat SSE + * stream stays out of scope). + */ +test.describe("Coach guided clarifying questions", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + const QUESTIONS = [ + "Guided e2e question one?", + "Guided e2e question two?", + "Guided e2e question three?", + ]; + + test.beforeEach(async ({ page }) => { + // Empty conversation list so the page surface paints the empty + // thread + history rail without hitting the database. + // Wide pattern on purpose: the list GET may fire with or without a + // cursor query. Neither the detail GET nor the SSE POST fires in + // this spec (no conversation is opened, no message is sent). + await page.route("**/api/insights/chat*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { conversations: [], nextCursor: null }, + error: null, + }), + }), + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { nudgedAt: null, unread: false }, + error: null, + }), + }), + ); + // Pending questions: GET serves the snapshot, DELETE (dismiss one + // or all) empties it. + await page.route("**/api/coach/about-me/questions*", (route) => { + const isDelete = route.request().method() === "DELETE"; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { questions: isDelete ? [] : QUESTIONS }, + error: null, + }), + }); + }); + }); + + test("offer card starts the guided sequence; skip and later walk it", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + // 1. Entry card with the three choices. + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await expect( + offer.locator('[data-slot="coach-guided-offer-start"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-later"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-dismiss"]'), + ).toBeVisible(); + + // 2. Start → first question bubble (typing reveal lasts <1s). + await offer.locator('[data-slot="coach-guided-offer-start"]').click(); + await expect(offer).toHaveCount(0); + const question = page.locator( + '[data-slot="coach-guided-question"][data-state="current"]', + ); + await expect(question).toBeVisible({ timeout: 10_000 }); + await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("1"); + + // 3. Skip → second question, original numbering. + await question.locator('[data-slot="coach-guided-skip"]').click(); + await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("2"); + + // 4. Later mid-sequence → flow ends, nothing answered → no summary, + // no question bubble, and the offer stays hidden for the session. + await question.locator('[data-slot="coach-guided-later"]').click(); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-summary"]'), + ).toHaveCount(0); + await expect(offer).toHaveCount(0); + }); + + test("entry-card later hides the offer without starting the flow", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await offer.locator('[data-slot="coach-guided-offer-later"]').click(); + await expect(offer).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + }); +}); diff --git a/messages/de.json b/messages/de.json index 5131c9de3..f59833f71 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1599,6 +1599,36 @@ "graceEcho": "Erinnerungsfenster: {minutes} Minuten", "reminderTitle": "Erinnerung", "manageChannels": "Benachrichtigungskanäle verwalten", + "history": { + "title": "Planhistorie", + "currentSince": "Aktueller Plan seit {date}", + "noTimes": "Keine festen Uhrzeiten", + "manualChip": "Manuell ergänzt", + "showErasOne": "1 frühere Ära anzeigen", + "showErasOther": "{count} frühere Ären anzeigen", + "hideEras": "Frühere Ären ausblenden", + "addEra": "Frühere Ära ergänzen", + "addEraTitle": "Frühere Ära ergänzen", + "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde — etwa bevor du es hier angepasst hast.", + "timesLabel": "Einnahmezeiten", + "fromLabel": "Von", + "untilLabel": "Bis", + "untilHint": "„Bis\" ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).", + "validationTimes": "Mindestens eine Uhrzeit angeben.", + "validationRangeMissing": "Von- und Bis-Datum angeben.", + "validationRangeOrder": "Das Von-Datum muss vor dem Bis-Datum liegen.", + "cancel": "Abbrechen", + "submit": "Ära speichern", + "createdToast": "Frühere Ära gespeichert", + "createError": "Die Ära konnte nicht gespeichert werden. Sie darf sich nicht mit bestehenden Ären überschneiden und muss vor dem aktuellen Plan enden.", + "deletedToast": "Ära gelöscht", + "deleteError": "Die Ära konnte nicht gelöscht werden", + "deleteAria": "Ära löschen", + "deleteConfirmTitle": "Ära löschen?", + "deleteConfirmBody": "Verlauf und Therapietreue werden für diesen Zeitraum wieder anhand des aktuellen Plans berechnet.", + "deleteConfirmAction": "Löschen", + "loadError": "Planhistorie konnte nicht geladen werden" + }, "save": "Zeitplan speichern", "saved": "Zeitplan gespeichert", "failed": "Zeitplan konnte nicht gespeichert werden", @@ -1995,7 +2025,6 @@ "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", - "selfContextChipsLabel": "Der Coach würde gern wissen:", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", @@ -2004,8 +2033,25 @@ "duplicate": "Bereits in der Selbstauskunft enthalten", "failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen." }, - "selfContextChipDismiss": "Frage verwerfen", - "selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden", + "guided": { + "offerTitle": "Der Coach hat {count} Rückfragen an dich", + "offerTitleOne": "Der Coach hat eine Rückfrage an dich", + "offerBody": "Kurze Antworten schärfen deine Selbstauskunft — eine Frage nach der anderen, direkt hier im Chat.", + "offerStart": "Jetzt beantworten", + "offerLater": "Später", + "offerDismiss": "Nicht mehr fragen", + "dismissError": "Die Fragen konnten nicht verworfen werden. Bitte erneut versuchen.", + "progress": "Frage {current} von {total}", + "skip": "Überspringen", + "later": "Später", + "dismissRemaining": "Nicht mehr fragen", + "answerPlaceholder": "Deine Antwort…", + "summaryTitle": "Geschafft — danke für deine Antworten!", + "summaryAnswered": "{answered} von {total} beantwortet", + "summaryAdopted": "{adopted} in die Selbstauskunft übernommen", + "summaryAdoptedNone": "nichts Neues in die Selbstauskunft übernommen", + "summaryLink": "Selbstauskunft ansehen" + }, "voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.", "threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.", "historyTitle": "Unterhaltungen", @@ -5109,10 +5155,21 @@ }, "coachNudge": { "title": "Proaktiver Coach", - "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.", + "description": "Meldet sich, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel. Themen und Häufigkeit sind einstellbar.", "toggleAria": "Proaktive Coach-Hinweise umschalten", - "statusOn": "Aktiv — max. 1× pro Woche", + "statusOn": "Aktiv", "statusOff": "Aus", + "groupsLabel": "Themen", + "groupMedication": "Medikation", + "groupMedicationDesc": "Lückenhafte Einnahmen der letzten Woche.", + "groupVitals": "Vitalwerte", + "groupVitalsDesc": "Blutdruck, Erholung, Gewichtstrend, Schlaf.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupte Mess-Pausen und Selbstauskunft-Check-ups.", + "frequencyLabel": "Häufigkeit", + "frequencyWeekly": "Höchstens einmal pro Woche", + "frequencyBiweekly": "Höchstens alle zwei Wochen", + "frequencyAria": "Häufigkeit der Coach-Hinweise wählen", "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." @@ -5312,6 +5369,13 @@ "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", + "weightTitle": "Dein Gewicht entfernt sich vom Zielbereich", + "weightBody": "Dein Wochenmittel hat sich weiter von deinem Zielbereich entfernt. Der Coach kann mit dir in Ruhe Bilanz ziehen.", + "sleepDebtTitle": "Mehrere kurze Nächte diese Woche", + "sleepDebtBody": "Die meisten deiner letzten Nächte lagen unter deinem Schlafziel. Der Coach hilft dir, nach Mustern zu suchen.", + "measurementGapTitle": "Dein Protokoll ist still geworden", + "measurementGapBody": "Seit einer Woche kommen keine neuen Werte an. Ein kurzer Austausch mit dem Coach hilft beim Wiedereinstieg — oder deckt eine hängende Synchronisierung auf.", + "focusSuffix": "Du wolltest auf Folgendes achten: {focus}.", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", diff --git a/messages/en.json b/messages/en.json index 75aa016ef..a31554d4b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1599,6 +1599,36 @@ "graceEcho": "Reminder window: {minutes} minutes", "reminderTitle": "Reminder", "manageChannels": "Manage notification channels", + "history": { + "title": "Plan history", + "currentSince": "Current plan since {date}", + "noTimes": "No fixed times", + "manualChip": "Added manually", + "showErasOne": "Show 1 earlier era", + "showErasOther": "Show {count} earlier eras", + "hideEras": "Hide earlier eras", + "addEra": "Add earlier era", + "addEraTitle": "Add earlier era", + "addEraDescription": "Record the times this medication used to be taken at — for example before you adjusted it here.", + "timesLabel": "Dose times", + "fromLabel": "From", + "untilLabel": "Until", + "untilHint": "\"Until\" is the day the following plan took over (exclusive).", + "validationTimes": "Add at least one time.", + "validationRangeMissing": "Set both the from and until dates.", + "validationRangeOrder": "The from date must lie before the until date.", + "cancel": "Cancel", + "submit": "Save era", + "createdToast": "Earlier era saved", + "createError": "Could not save the era. It must not overlap existing eras and must end before the current plan.", + "deletedToast": "Era deleted", + "deleteError": "Could not delete the era", + "deleteAria": "Delete era", + "deleteConfirmTitle": "Delete era?", + "deleteConfirmBody": "History and adherence for this period will be computed against the current plan again.", + "deleteConfirmAction": "Delete", + "loadError": "Could not load the plan history" + }, "save": "Save schedule", "saved": "Schedule saved", "failed": "Could not save the schedule", @@ -1995,7 +2025,6 @@ "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", - "selfContextChipsLabel": "The Coach would like to know:", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", @@ -2004,8 +2033,25 @@ "duplicate": "Already part of your self-context", "failed": "Could not add. Please try again." }, - "selfContextChipDismiss": "Dismiss question", - "selfContextChipDismissError": "Could not dismiss the question", + "guided": { + "offerTitle": "The Coach has {count} follow-up questions for you", + "offerTitleOne": "The Coach has a follow-up question for you", + "offerBody": "Short answers sharpen your self-context — one question at a time, right here in the chat.", + "offerStart": "Answer now", + "offerLater": "Later", + "offerDismiss": "Don't ask again", + "dismissError": "Could not dismiss the questions. Please try again.", + "progress": "Question {current} of {total}", + "skip": "Skip", + "later": "Later", + "dismissRemaining": "Don't ask again", + "answerPlaceholder": "Type your answer…", + "summaryTitle": "All set — thanks for your answers!", + "summaryAnswered": "{answered} of {total} answered", + "summaryAdopted": "{adopted} added to your self-context", + "summaryAdoptedNone": "nothing new added to your self-context", + "summaryLink": "Review your self-context" + }, "voiceComingSoon": "Voice input arrives with the iOS app in v1.5.", "threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.", "historyTitle": "Conversations", @@ -5109,10 +5155,21 @@ }, "coachNudge": { "title": "Proactive Coach", - "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.", + "description": "Reaches out when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average. Topics and frequency are adjustable.", "toggleAria": "Toggle proactive Coach nudges", - "statusOn": "Active — max. once a week", + "statusOn": "Active", "statusOff": "Off", + "groupsLabel": "Topics", + "groupMedication": "Medication", + "groupMedicationDesc": "Patchy intakes over the past week.", + "groupVitals": "Vitals", + "groupVitalsDesc": "Blood pressure, recovery, weight trend, sleep.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupt tracking stops and self-context check-ups.", + "frequencyLabel": "Frequency", + "frequencyWeekly": "At most once a week", + "frequencyBiweekly": "At most every two weeks", + "frequencyAria": "Select nudge frequency", "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", "saveError": "Could not save. Please try again later." @@ -5312,6 +5369,13 @@ "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", "selfContextTitle": "Help your Coach know you better", "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", + "weightTitle": "Your weight is drifting from your target", + "weightBody": "Your weekly average moved further away from your target range. The Coach can help you take stock calmly.", + "sleepDebtTitle": "Several short nights this week", + "sleepDebtBody": "Most of your recent nights came in under your sleep target. The Coach can help you look for patterns.", + "measurementGapTitle": "Your log went quiet", + "measurementGapBody": "No new readings arrived for a week. A quick word with the Coach helps you pick the routine back up — or spot a broken sync.", + "focusSuffix": "You wanted to keep an eye on: {focus}.", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", diff --git a/messages/es.json b/messages/es.json index 4fe64b082..64d69e29f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1599,6 +1599,36 @@ "graceEcho": "Ventana de recordatorio: {minutes} minutos", "reminderTitle": "Recordatorio", "manageChannels": "Gestionar canales de notificación", + "history": { + "title": "Historial del plan", + "currentSince": "Plan actual desde {date}", + "noTimes": "Sin horas fijas", + "manualChip": "Añadida manualmente", + "showErasOne": "Mostrar 1 etapa anterior", + "showErasOther": "Mostrar {count} etapas anteriores", + "hideEras": "Ocultar etapas anteriores", + "addEra": "Añadir etapa anterior", + "addEraTitle": "Añadir etapa anterior", + "addEraDescription": "Registra a qué horas se tomaba antes este medicamento, por ejemplo antes de ajustarlo aquí.", + "timesLabel": "Horas de toma", + "fromLabel": "Desde", + "untilLabel": "Hasta", + "untilHint": "«Hasta» es el día en que empezó a regir el plan siguiente (exclusivo).", + "validationTimes": "Indica al menos una hora.", + "validationRangeMissing": "Indica las fechas de inicio y fin.", + "validationRangeOrder": "La fecha de inicio debe ser anterior a la fecha de fin.", + "cancel": "Cancelar", + "submit": "Guardar etapa", + "createdToast": "Etapa anterior guardada", + "createError": "No se pudo guardar la etapa. No debe solaparse con etapas existentes y debe terminar antes del plan actual.", + "deletedToast": "Etapa eliminada", + "deleteError": "No se pudo eliminar la etapa", + "deleteAria": "Eliminar etapa", + "deleteConfirmTitle": "¿Eliminar la etapa?", + "deleteConfirmBody": "El historial y la adherencia de este período volverán a calcularse con el plan actual.", + "deleteConfirmAction": "Eliminar", + "loadError": "No se pudo cargar el historial del plan" + }, "save": "Guardar horario", "saved": "Horario guardado", "failed": "No se pudo guardar el horario", @@ -1995,7 +2025,6 @@ "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", - "selfContextChipsLabel": "Al Coach le gustaría saber:", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", @@ -2004,8 +2033,25 @@ "duplicate": "Ya está en tu información personal", "failed": "No se pudo añadir. Inténtalo de nuevo." }, - "selfContextChipDismiss": "Descartar pregunta", - "selfContextChipDismissError": "No se pudo descartar la pregunta", + "guided": { + "offerTitle": "El Coach tiene {count} preguntas para ti", + "offerTitleOne": "El Coach tiene una pregunta para ti", + "offerBody": "Respuestas breves afinan tu información personal — una pregunta a la vez, aquí en el chat.", + "offerStart": "Responder ahora", + "offerLater": "Más tarde", + "offerDismiss": "No volver a preguntar", + "dismissError": "No se pudieron descartar las preguntas. Inténtalo de nuevo.", + "progress": "Pregunta {current} de {total}", + "skip": "Omitir", + "later": "Más tarde", + "dismissRemaining": "No volver a preguntar", + "answerPlaceholder": "Tu respuesta…", + "summaryTitle": "¡Listo — gracias por tus respuestas!", + "summaryAnswered": "{answered} de {total} respondidas", + "summaryAdopted": "{adopted} añadidas a tu información personal", + "summaryAdoptedNone": "no se añadió nada nuevo a tu información personal", + "summaryLink": "Ver tu información personal" + }, "voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.", "threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.", "historyTitle": "Conversaciones", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proactivo", - "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.", + "description": "Se pone en contacto cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada. Los temas y la frecuencia son ajustables.", "toggleAria": "Activar o desactivar los avisos proactivos del Coach", - "statusOn": "Activo — máx. 1 vez por semana", + "statusOn": "Activo", + "groupsLabel": "Temas", + "groupMedication": "Medicación", + "groupMedicationDesc": "Tomas irregulares durante la última semana.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tensión, recuperación, tendencia del peso, sueño.", + "groupRoutine": "Rutina", + "groupRoutineDesc": "Pausas bruscas en los registros y revisiones de la autodescripción.", + "frequencyLabel": "Frecuencia", + "frequencyWeekly": "Como máximo una vez por semana", + "frequencyBiweekly": "Como máximo cada dos semanas", + "frequencyAria": "Seleccionar la frecuencia de los avisos", "statusOff": "Desactivado", "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", @@ -5312,6 +5369,13 @@ "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", + "weightTitle": "Tu peso se aleja de tu rango objetivo", + "weightBody": "Tu media semanal se ha alejado más de tu rango objetivo. El Coach puede ayudarte a hacer balance con calma.", + "sleepDebtTitle": "Varias noches cortas esta semana", + "sleepDebtBody": "La mayoría de tus últimas noches quedaron por debajo de tu objetivo de sueño. El Coach puede ayudarte a buscar patrones.", + "measurementGapTitle": "Tu registro se ha quedado en silencio", + "measurementGapBody": "Hace una semana que no llegan valores nuevos. Una breve charla con el Coach ayuda a retomar la rutina — o a detectar una sincronización rota.", + "focusSuffix": "Querías prestar atención a: {focus}.", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", diff --git a/messages/fr.json b/messages/fr.json index bf128ed8b..3a65daf4c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1599,6 +1599,36 @@ "graceEcho": "Fenêtre de rappel : {minutes} minutes", "reminderTitle": "Rappel", "manageChannels": "Gérer les canaux de notification", + "history": { + "title": "Historique du plan", + "currentSince": "Plan actuel depuis {date}", + "noTimes": "Aucune heure fixe", + "manualChip": "Ajoutée manuellement", + "showErasOne": "Afficher 1 période antérieure", + "showErasOther": "Afficher {count} périodes antérieures", + "hideEras": "Masquer les périodes antérieures", + "addEra": "Ajouter une période antérieure", + "addEraTitle": "Ajouter une période antérieure", + "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant — par exemple avant de l'ajuster ici.", + "timesLabel": "Heures de prise", + "fromLabel": "Du", + "untilLabel": "Jusqu'au", + "untilHint": "« Jusqu'au » est le jour où le plan suivant a pris effet (exclusif).", + "validationTimes": "Indiquez au moins une heure.", + "validationRangeMissing": "Indiquez les dates de début et de fin.", + "validationRangeOrder": "La date de début doit précéder la date de fin.", + "cancel": "Annuler", + "submit": "Enregistrer la période", + "createdToast": "Période antérieure enregistrée", + "createError": "Impossible d'enregistrer la période. Elle ne doit pas chevaucher des périodes existantes et doit se terminer avant le plan actuel.", + "deletedToast": "Période supprimée", + "deleteError": "Impossible de supprimer la période", + "deleteAria": "Supprimer la période", + "deleteConfirmTitle": "Supprimer la période ?", + "deleteConfirmBody": "L'historique et l'observance de cette période seront recalculés selon le plan actuel.", + "deleteConfirmAction": "Supprimer", + "loadError": "Impossible de charger l'historique du plan" + }, "save": "Enregistrer l'horaire", "saved": "Horaire enregistré", "failed": "Impossible d'enregistrer l'horaire", @@ -1995,7 +2025,6 @@ "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", - "selfContextChipsLabel": "Le Coach aimerait savoir :", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", @@ -2004,8 +2033,25 @@ "duplicate": "Déjà présent dans vos informations personnelles", "failed": "Échec de l’ajout. Veuillez réessayer." }, - "selfContextChipDismiss": "Ignorer la question", - "selfContextChipDismissError": "Impossible de masquer la question", + "guided": { + "offerTitle": "Le Coach a {count} questions pour vous", + "offerTitleOne": "Le Coach a une question pour vous", + "offerBody": "De courtes réponses affinent vos informations personnelles — une question à la fois, ici dans le chat.", + "offerStart": "Répondre maintenant", + "offerLater": "Plus tard", + "offerDismiss": "Ne plus demander", + "dismissError": "Impossible d’écarter les questions. Veuillez réessayer.", + "progress": "Question {current} sur {total}", + "skip": "Passer", + "later": "Plus tard", + "dismissRemaining": "Ne plus demander", + "answerPlaceholder": "Votre réponse…", + "summaryTitle": "C’est fait — merci pour vos réponses !", + "summaryAnswered": "{answered} sur {total} répondues", + "summaryAdopted": "{adopted} ajoutées à vos informations personnelles", + "summaryAdoptedNone": "rien de nouveau ajouté à vos informations personnelles", + "summaryLink": "Voir vos informations personnelles" + }, "voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.", "threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.", "historyTitle": "Conversations", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proactif", - "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.", + "description": "Vous contacte lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée. Les thèmes et la fréquence sont réglables.", "toggleAria": "Activer ou désactiver les rappels proactifs du Coach", - "statusOn": "Actif — max. 1 fois par semaine", + "statusOn": "Actif", + "groupsLabel": "Thèmes", + "groupMedication": "Médication", + "groupMedicationDesc": "Prises irrégulières au cours de la dernière semaine.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tension, récupération, tendance du poids, sommeil.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Arrêts brusques des mesures et bilans de l'auto-description.", + "frequencyLabel": "Fréquence", + "frequencyWeekly": "Au plus une fois par semaine", + "frequencyBiweekly": "Au plus toutes les deux semaines", + "frequencyAria": "Choisir la fréquence des rappels", "statusOff": "Désactivé", "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", @@ -5312,6 +5369,13 @@ "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", "selfContextTitle": "Aidez votre Coach à mieux vous connaître", "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", + "weightTitle": "Votre poids s'éloigne de votre zone cible", + "weightBody": "Votre moyenne hebdomadaire s'est encore éloignée de votre zone cible. Le Coach peut vous aider à faire le point sereinement.", + "sleepDebtTitle": "Plusieurs nuits courtes cette semaine", + "sleepDebtBody": "La plupart de vos dernières nuits sont restées sous votre objectif de sommeil. Le Coach peut vous aider à chercher des régularités.", + "measurementGapTitle": "Votre journal est devenu silencieux", + "measurementGapBody": "Aucune nouvelle valeur depuis une semaine. Un bref échange avec le Coach aide à reprendre la routine — ou à repérer une synchronisation en panne.", + "focusSuffix": "Vous vouliez surveiller : {focus}.", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", diff --git a/messages/it.json b/messages/it.json index b47026e16..b69b182de 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1599,6 +1599,36 @@ "graceEcho": "Finestra di promemoria: {minutes} minuti", "reminderTitle": "Promemoria", "manageChannels": "Gestisci i canali di notifica", + "history": { + "title": "Cronologia del piano", + "currentSince": "Piano attuale dal {date}", + "noTimes": "Nessun orario fisso", + "manualChip": "Aggiunta manualmente", + "showErasOne": "Mostra 1 periodo precedente", + "showErasOther": "Mostra {count} periodi precedenti", + "hideEras": "Nascondi i periodi precedenti", + "addEra": "Aggiungi periodo precedente", + "addEraTitle": "Aggiungi periodo precedente", + "addEraDescription": "Registra a che ora veniva assunto questo farmaco in passato, ad esempio prima di modificarlo qui.", + "timesLabel": "Orari di assunzione", + "fromLabel": "Dal", + "untilLabel": "Fino al", + "untilHint": "«Fino al» è il giorno in cui è entrato in vigore il piano successivo (escluso).", + "validationTimes": "Indica almeno un orario.", + "validationRangeMissing": "Indica le date di inizio e fine.", + "validationRangeOrder": "La data di inizio deve precedere la data di fine.", + "cancel": "Annulla", + "submit": "Salva periodo", + "createdToast": "Periodo precedente salvato", + "createError": "Impossibile salvare il periodo. Non deve sovrapporsi a periodi esistenti e deve terminare prima del piano attuale.", + "deletedToast": "Periodo eliminato", + "deleteError": "Impossibile eliminare il periodo", + "deleteAria": "Elimina periodo", + "deleteConfirmTitle": "Eliminare il periodo?", + "deleteConfirmBody": "La cronologia e l'aderenza di questo intervallo verranno ricalcolate in base al piano attuale.", + "deleteConfirmAction": "Elimina", + "loadError": "Impossibile caricare la cronologia del piano" + }, "save": "Salva programma", "saved": "Programma salvato", "failed": "Impossibile salvare il programma", @@ -1995,7 +2025,6 @@ "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", - "selfContextChipsLabel": "Il Coach vorrebbe sapere:", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", @@ -2004,8 +2033,25 @@ "duplicate": "Già presente nelle tue informazioni personali", "failed": "Aggiunta non riuscita. Riprova." }, - "selfContextChipDismiss": "Ignora la domanda", - "selfContextChipDismissError": "Impossibile ignorare la domanda", + "guided": { + "offerTitle": "Il Coach ha {count} domande per te", + "offerTitleOne": "Il Coach ha una domanda per te", + "offerBody": "Risposte brevi affinano le tue informazioni personali — una domanda alla volta, qui in chat.", + "offerStart": "Rispondi ora", + "offerLater": "Più tardi", + "offerDismiss": "Non chiedere più", + "dismissError": "Impossibile scartare le domande. Riprova.", + "progress": "Domanda {current} di {total}", + "skip": "Salta", + "later": "Più tardi", + "dismissRemaining": "Non chiedere più", + "answerPlaceholder": "La tua risposta…", + "summaryTitle": "Fatto — grazie per le tue risposte!", + "summaryAnswered": "{answered} di {total} risposte", + "summaryAdopted": "{adopted} aggiunte alle tue informazioni personali", + "summaryAdoptedNone": "nulla di nuovo aggiunto alle tue informazioni personali", + "summaryLink": "Vedi le tue informazioni personali" + }, "voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.", "threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.", "historyTitle": "Conversazioni", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proattivo", - "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.", + "description": "Si fa sentire quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata. Temi e frequenza sono regolabili.", "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach", - "statusOn": "Attivo — max 1 volta a settimana", + "statusOn": "Attivo", + "groupsLabel": "Temi", + "groupMedication": "Terapia", + "groupMedicationDesc": "Assunzioni irregolari nell'ultima settimana.", + "groupVitals": "Parametri vitali", + "groupVitalsDesc": "Pressione, recupero, tendenza del peso, sonno.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Interruzioni improvvise delle misurazioni e controlli dell'autodescrizione.", + "frequencyLabel": "Frequenza", + "frequencyWeekly": "Al massimo una volta a settimana", + "frequencyBiweekly": "Al massimo ogni due settimane", + "frequencyAria": "Seleziona la frequenza degli avvisi", "statusOff": "Disattivato", "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", @@ -5312,6 +5369,13 @@ "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", + "weightTitle": "Il tuo peso si sta allontanando dall'obiettivo", + "weightBody": "La tua media settimanale si è allontanata ulteriormente dal tuo intervallo obiettivo. Il Coach può aiutarti a fare il punto con calma.", + "sleepDebtTitle": "Diverse notti brevi questa settimana", + "sleepDebtBody": "La maggior parte delle tue ultime notti è rimasta sotto il tuo obiettivo di sonno. Il Coach può aiutarti a cercare schemi ricorrenti.", + "measurementGapTitle": "Il tuo diario è rimasto in silenzio", + "measurementGapBody": "Da una settimana non arrivano nuovi valori. Due parole con il Coach aiutano a riprendere la routine — o a scoprire una sincronizzazione interrotta.", + "focusSuffix": "Volevi tenere d'occhio: {focus}.", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", diff --git a/messages/pl.json b/messages/pl.json index 2cca67983..3c2de5a51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1599,6 +1599,36 @@ "graceEcho": "Okno przypomnienia: {minutes} minut", "reminderTitle": "Przypomnienie", "manageChannels": "Zarządzaj kanałami powiadomień", + "history": { + "title": "Historia planu", + "currentSince": "Aktualny plan od {date}", + "noTimes": "Brak stałych godzin", + "manualChip": "Dodano ręcznie", + "showErasOne": "Pokaż 1 wcześniejszy okres", + "showErasOther": "Pokaż wcześniejsze okresy ({count})", + "hideEras": "Ukryj wcześniejsze okresy", + "addEra": "Dodaj wcześniejszy okres", + "addEraTitle": "Dodaj wcześniejszy okres", + "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany — np. zanim zmieniono go tutaj.", + "timesLabel": "Godziny przyjmowania", + "fromLabel": "Od", + "untilLabel": "Do", + "untilHint": "„Do\" to dzień, od którego obowiązywał kolejny plan (wyłącznie).", + "validationTimes": "Podaj co najmniej jedną godzinę.", + "validationRangeMissing": "Podaj daty od i do.", + "validationRangeOrder": "Data od musi być wcześniejsza niż data do.", + "cancel": "Anuluj", + "submit": "Zapisz okres", + "createdToast": "Wcześniejszy okres zapisany", + "createError": "Nie udało się zapisać okresu. Nie może nakładać się na istniejące okresy i musi kończyć się przed aktualnym planem.", + "deletedToast": "Okres usunięty", + "deleteError": "Nie udało się usunąć okresu", + "deleteAria": "Usuń okres", + "deleteConfirmTitle": "Usunąć okres?", + "deleteConfirmBody": "Historia i przestrzeganie zaleceń w tym zakresie zostaną ponownie obliczone według aktualnego planu.", + "deleteConfirmAction": "Usuń", + "loadError": "Nie udało się wczytać historii planu" + }, "save": "Zapisz harmonogram", "saved": "Zapisano harmonogram", "failed": "Nie udało się zapisać harmonogramu", @@ -1995,7 +2025,6 @@ "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", - "selfContextChipsLabel": "Coach chciałby wiedzieć:", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", @@ -2004,8 +2033,25 @@ "duplicate": "Już znajduje się w informacjach o sobie", "failed": "Nie udało się dodać. Spróbuj ponownie." }, - "selfContextChipDismiss": "Odrzuć pytanie", - "selfContextChipDismissError": "Nie udało się odrzucić pytania", + "guided": { + "offerTitle": "Coach ma {count} pytania do Ciebie", + "offerTitleOne": "Coach ma pytanie do Ciebie", + "offerBody": "Krótkie odpowiedzi doprecyzują Twoje informacje o sobie — jedno pytanie po drugim, tutaj na czacie.", + "offerStart": "Odpowiedz teraz", + "offerLater": "Później", + "offerDismiss": "Nie pytaj więcej", + "dismissError": "Nie udało się odrzucić pytań. Spróbuj ponownie.", + "progress": "Pytanie {current} z {total}", + "skip": "Pomiń", + "later": "Później", + "dismissRemaining": "Nie pytaj więcej", + "answerPlaceholder": "Twoja odpowiedź…", + "summaryTitle": "Gotowe — dzięki za odpowiedzi!", + "summaryAnswered": "odpowiedziano na {answered} z {total}", + "summaryAdopted": "{adopted} dodano do informacji o sobie", + "summaryAdoptedNone": "nie dodano nic nowego do informacji o sobie", + "summaryLink": "Zobacz informacje o sobie" + }, "voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.", "threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.", "historyTitle": "Rozmowy", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Proaktywny Coach", - "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.", + "description": "Odzywa się, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia. Tematy i częstotliwość można dostosować.", "toggleAria": "Przełącz proaktywne podpowiedzi Coacha", - "statusOn": "Aktywne — maks. 1 raz w tygodniu", + "statusOn": "Aktywne", + "groupsLabel": "Tematy", + "groupMedication": "Leki", + "groupMedicationDesc": "Nieregularne przyjmowanie leków w ostatnim tygodniu.", + "groupVitals": "Parametry życiowe", + "groupVitalsDesc": "Ciśnienie, regeneracja, trend wagi, sen.", + "groupRoutine": "Rutyna", + "groupRoutineDesc": "Nagłe przerwy w pomiarach i przegląd opisu o sobie.", + "frequencyLabel": "Częstotliwość", + "frequencyWeekly": "Najwyżej raz w tygodniu", + "frequencyBiweekly": "Najwyżej co dwa tygodnie", + "frequencyAria": "Wybierz częstotliwość podpowiedzi", "statusOff": "Wyłączone", "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", @@ -5312,6 +5369,13 @@ "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", + "weightTitle": "Twoja waga oddala się od zakresu docelowego", + "weightBody": "Twoja tygodniowa średnia jeszcze bardziej oddaliła się od zakresu docelowego. Coach pomoże Ci spokojnie podsumować sytuację.", + "sleepDebtTitle": "Kilka krótkich nocy w tym tygodniu", + "sleepDebtBody": "Większość Twoich ostatnich nocy była poniżej celu snu. Coach pomoże Ci poszukać wzorców.", + "measurementGapTitle": "Twój dziennik ucichł", + "measurementGapBody": "Od tygodnia nie pojawiają się nowe wartości. Krótka rozmowa z Coachem pomoże wrócić do rutyny — albo wykryć zepsutą synchronizację.", + "focusSuffix": "Chciałeś/chciałaś zwracać uwagę na: {focus}.", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8632c2e7d..3137ecb03 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -91,7 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -112,7 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -135,7 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); }); @@ -169,7 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -179,7 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -193,13 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, changed: ["medication"], }), @@ -261,7 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -287,7 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -297,7 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -326,7 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); diff --git a/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx b/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx new file mode 100644 index 000000000..e79a5c3c1 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { + GuidedQuestionBubble, + GuidedSummaryBubble, +} from "../guided-dialog-bubbles"; +import { GuidedQuestionsCard } from "../guided-questions-card"; + +/** + * v1.16.5 — markup-level coverage for the guided clarifying-questions + * surfaces (entry card, question bubble, summary bubble). Behavioural + * transitions live in `guided-questions-machine.test.ts`; placement in + * the thread lives with the `placeInterleaved` tests. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const noop = () => {}; + +describe("", () => { + it("renders the plural offer with the count and all three choices", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-offer"'); + expect(html).toContain("The Coach has 3 follow-up questions for you"); + expect(html).toContain('data-slot="coach-guided-offer-start"'); + expect(html).toContain('data-slot="coach-guided-offer-later"'); + expect(html).toContain('data-slot="coach-guided-offer-dismiss"'); + expect(html).toContain("Answer now"); + }); + + it("uses the singular title for a single pending question", () => { + const html = render( + , + ); + expect(html).toContain("The Coach has a follow-up question for you"); + expect(html).not.toContain("{count}"); + }); + + it("disables every action while a request is in flight", () => { + const html = render( + , + ); + expect(html.match(/disabled=""/g)?.length).toBe(3); + }); +}); + +describe("", () => { + it("renders progress + question text for the current question with actions", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-question"'); + expect(html).toContain('data-state="current"'); + expect(html).toContain("Question 1 of 3"); + expect(html).toContain("Do you have any chronic conditions?"); + expect(html).toContain('data-slot="coach-guided-skip"'); + expect(html).toContain('data-slot="coach-guided-later"'); + expect(html).toContain('data-slot="coach-guided-dismiss"'); + }); + + it("renders answered questions static — no actions", () => { + const html = render( + , + ); + expect(html).toContain('data-state="answered"'); + expect(html).not.toContain('data-slot="coach-guided-actions"'); + }); + + it("localises the progress label", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Frage 2 von 3"); + }); +}); + +describe("", () => { + it("recaps answered + adopted counts and links to the self-context", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-summary"'); + expect(html).toContain("2 of 3 answered"); + expect(html).toContain("1 added to your self-context"); + expect(html).toContain('href="/settings/ai"'); + }); + + it("says so when nothing was adopted", () => { + const html = render( + , + ); + expect(html).toContain("nothing new added to your self-context"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts b/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts new file mode 100644 index 000000000..521f129c7 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + countAdopted, + deriveThreadItems, + GUIDED_IDLE, + guidedReducer, + type GuidedEvent, + type GuidedState, +} from "../guided-questions-machine"; + +/** + * v1.16.5 — guided clarifying-questions state machine. + * + * Pure-reducer coverage for the in-chat guided flow: start / later / + * answer / skip / exit / dismiss transitions, adoption settling, and + * the thread-item derivation the conversation surface renders from. + */ + +const QUESTIONS = [ + "Do you have any chronic conditions?", + "What should the Coach focus on?", + "Any allergies worth noting?", +]; + +function run(events: GuidedEvent[], from: GuidedState = GUIDED_IDLE) { + return events.reduce(guidedReducer, from); +} + +describe("guidedReducer — entry", () => { + it("START snapshots the questions and asks the first", () => { + const state = run([{ type: "START", questions: QUESTIONS }]); + expect(state).toEqual({ + phase: "asking", + questions: QUESTIONS, + index: 0, + outcomes: [], + }); + }); + + it("START with no questions is a no-op", () => { + expect(run([{ type: "START", questions: [] }])).toEqual(GUIDED_IDLE); + }); + + it("START mid-flow is ignored (no sequence restart)", () => { + const asking = run([{ type: "START", questions: QUESTIONS }]); + expect(guidedReducer(asking, { type: "START", questions: ["x"] })).toBe( + asking, + ); + }); + + it("LATER from idle hides the offer for the session", () => { + expect(run([{ type: "LATER" }])).toEqual({ phase: "done" }); + }); + + it("LATER mid-flow is ignored — the bubble actions own the exits", () => { + const asking = run([{ type: "START", questions: QUESTIONS }]); + expect(guidedReducer(asking, { type: "LATER" })).toBe(asking); + }); +}); + +describe("guidedReducer — answering", () => { + it("ANSWER_SUBMITTED records the outcome and waits for the turn", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + ]); + expect(state.phase).toBe("answered"); + if (state.phase !== "answered") throw new Error("unreachable"); + expect(state.outcomes).toEqual([ + { + index: 0, + question: QUESTIONS[0], + answer: "Hypertension.", + adoption: "pending", + }, + ]); + }); + + it("TURN_COMPLETE advances to the next question", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]); + expect(state).toMatchObject({ phase: "asking", index: 1 }); + }); + + it("TURN_COMPLETE after the last question lands on the summary", () => { + const state = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + ]); + expect(state.phase).toBe("summary"); + if (state.phase !== "summary") throw new Error("unreachable"); + expect(state.outcomes).toHaveLength(1); + }); + + it("composer submits outside the asking phase change nothing", () => { + const summary = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + ]); + expect( + guidedReducer(summary, { type: "ANSWER_SUBMITTED", answer: "more" }), + ).toBe(summary); + }); +}); + +describe("guidedReducer — skip / exit / reset", () => { + it("SKIP advances without an outcome (question stays pending)", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + ]); + expect(state).toMatchObject({ phase: "asking", index: 1, outcomes: [] }); + }); + + it("skipping every question ends without a summary", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + { type: "SKIP" }, + { type: "SKIP" }, + ]); + expect(state).toEqual({ phase: "done" }); + }); + + it("SKIP on the last question shows the summary when answers exist", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "SKIP" }, + { type: "SKIP" }, + ]); + expect(state.phase).toBe("summary"); + }); + + it("EXIT with answers lands on the summary; without answers it just ends", () => { + const withAnswer = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "EXIT" }, + ]); + expect(withAnswer.phase).toBe("summary"); + + const withoutAnswer = run([ + { type: "START", questions: QUESTIONS }, + { type: "EXIT" }, + ]); + expect(withoutAnswer).toEqual({ phase: "done" }); + }); + + it("RESET drops any session back to idle", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "RESET" }, + ]); + expect(state).toEqual(GUIDED_IDLE); + }); +}); + +describe("guidedReducer — adoption settling", () => { + const answeredOne: GuidedEvent[] = [ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]; + + it("settles the matching pending outcome", () => { + const state = run([ + ...answeredOne, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "asking") throw new Error("unreachable"); + expect(state.outcomes[0].adoption).toBe("adopted"); + }); + + it("never resettles an already-settled outcome", () => { + const state = run([ + ...answeredOne, + { type: "ADOPTION_SETTLED", index: 0, adoption: "declined" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "asking") throw new Error("unreachable"); + expect(state.outcomes[0].adoption).toBe("declined"); + }); + + it("still settles outcomes while the summary is showing", () => { + const state = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "summary") throw new Error("unreachable"); + expect(countAdopted(state.outcomes)).toBe(1); + }); +}); + +describe("deriveThreadItems", () => { + it("idle and done contribute nothing", () => { + expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]); + expect(deriveThreadItems({ phase: "done" })).toEqual([]); + }); + + it("asking renders answered questions anchored + the current at the tail", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + kind: "question", + question: QUESTIONS[0], + progress: { current: 1, total: 3 }, + anchorAnswer: "Hypertension.", + }); + expect(items[1]).toMatchObject({ + kind: "question", + question: QUESTIONS[1], + progress: { current: 2, total: 3 }, + anchorAnswer: null, + current: true, + }); + }); + + it("a skipped question keeps the original numbering on the next one", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(1); + expect(items[0].progress).toEqual({ current: 2, total: 3 }); + }); + + it("answered phase has no current item (the turn is streaming)", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(1); + expect(items[0].anchorAnswer).toBe("Hypertension."); + }); + + it("summary carries answered / adopted / total counts", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + { type: "ANSWER_SUBMITTED", answer: "Sleep." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 1, adoption: "declined" }, + { type: "SKIP" }, + ]); + const items = deriveThreadItems(state); + const summary = items.at(-1); + expect(summary).toMatchObject({ + kind: "summary", + anchorAnswer: null, + summary: { answered: 2, adopted: 1, total: 3 }, + }); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index fb6748885..53dd831fb 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -52,7 +52,11 @@ vi.mock("@tanstack/react-query", () => ({ }), })); -import { MessageThread } from "../message-thread"; +import { + MessageThread, + placeInterleaved, + type InterleavedThreadItem, +} from "../message-thread"; import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -599,3 +603,126 @@ describe("errorCodeToI18nKey", () => { ); }); }); + +/** + * v1.16.5 — placement of guided-flow bubbles between the persisted + * messages, the optimistic user bubble, and the thread tail. + */ +describe("placeInterleaved", () => { + const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({ + key, + anchorAnswer, + node: , + }); + const user = (id: string, content: string) => ({ + id, + role: "user", + content, + }); + const assistant = (id: string, content: string) => ({ + id, + role: "assistant", + content, + }); + + it("anchors an answered question before its persisted user message", () => { + const placed = placeInterleaved( + [item("q1", "My answer"), item("q2", null)], + [ + user("m1", "Earlier turn"), + assistant("m2", "Earlier reply"), + user("m3", "My answer"), + assistant("m4", "Reply"), + ], + null, + ); + expect(placed.before.get("m3")?.key).toBe("q1"); + expect(placed.beforeOptimistic).toEqual([]); + expect(placed.tail.map((i) => i.key)).toEqual(["q2"]); + }); + + it("anchors before the optimistic bubble while the twin is in flight", () => { + const placed = placeInterleaved( + [item("q1", "My answer")], + [user("m1", "Earlier turn")], + "My answer", + ); + expect(placed.before.size).toBe(0); + expect(placed.beforeOptimistic.map((i) => i.key)).toEqual(["q1"]); + expect(placed.tail).toEqual([]); + }); + + it("matches assistant-content collisions never (user messages only)", () => { + const placed = placeInterleaved( + [item("q1", "My answer")], + [assistant("m1", "My answer")], + null, + ); + expect(placed.before.size).toBe(0); + expect(placed.tail.map((i) => i.key)).toEqual(["q1"]); + }); + + it("consumes duplicate answers in order", () => { + const placed = placeInterleaved( + [item("q1", "Yes"), item("q2", "Yes")], + [user("m1", "Yes"), assistant("m2", "r"), user("m3", "Yes")], + null, + ); + expect(placed.before.get("m1")?.key).toBe("q1"); + expect(placed.before.get("m3")?.key).toBe("q2"); + }); + + it("falls back to the tail when an anchor never matches", () => { + const placed = placeInterleaved( + [item("q1", "Lost answer"), item("summary", null)], + [user("m1", "Something else")], + null, + ); + expect(placed.before.size).toBe(0); + expect(placed.tail.map((i) => i.key)).toEqual(["q1", "summary"]); + }); +}); + +describe(" interleaved guided bubbles", () => { + it("renders an unanchored item at the tail even on an empty thread", () => { + const html = render( + First question?
      , + }, + ]} + />, + ); + // The guided bubble suppresses the empty-state hero… + expect(html).not.toContain( + "Ask anything about your trends, medications, or readings", + ); + // …and renders inside the scroller. + expect(html).toContain('data-slot="guided-test-bubble"'); + expect(html).toContain("First question?"); + }); + + it("renders an anchored item before the user message that answered it", () => { + const html = render( + Guided question?, + }, + ]} + />, + ); + const guidedAt = html.indexOf('data-slot="guided-test-bubble"'); + const userAt = html.indexOf('data-slot="coach-bubble-user"'); + expect(guidedAt).toBeGreaterThan(-1); + expect(userAt).toBeGreaterThan(-1); + expect(guidedAt).toBeLessThan(userAt); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index b09b065d0..454bf2b10 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,20 +1,33 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Settings, Sparkles } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; +import { + GuidedQuestionBubble, + GuidedSummaryBubble, +} from "./guided-dialog-bubbles"; +import { GuidedQuestionsCard } from "./guided-questions-card"; +import { + deriveThreadItems, + GUIDED_IDLE, + guidedReducer, +} from "./guided-questions-machine"; import { HistoryRail } from "./history-rail"; -import { MessageThread } from "./message-thread"; +import { MessageThread, type InterleavedThreadItem } from "./message-thread"; import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; -import { SelfContextChips } from "./self-context-chips"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; import { useCoachConversation, useSendCoachMessage } from "./use-coach"; @@ -114,18 +127,46 @@ export function CoachConversation({ const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); - // v1.16.4 — self-context backflow. `activeChipQuestion` remembers the - // clarifying-question chip the user tapped (the chip pre-fills the - // composer); when the next message answers it, `pendingAdopt` raises a - // quiet offer to fold the answer back into the Selbstauskunft. - const [activeChipQuestion, setActiveChipQuestion] = useState( - null, - ); + // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet + // offer to fold a clarifying-question answer back into the + // Selbstauskunft. v1.16.5 — the answers now come from the guided + // flow; `guidedIndex` routes the offer's outcome back into the + // machine so the closing summary can say what was adopted. const [pendingAdopt, setPendingAdopt] = useState<{ question: string; answer: string; + guidedIndex: number; } | null>(null); + // v1.16.5 — guided clarifying-questions flow (V2 of the v1.16.0 + // chips). The machine is pure (`guided-questions-machine.ts`); this + // component owns the server round-trips: the pending-questions query + // feeding the entry card, the per-question dismiss on answer, and + // the dismiss-all behind "don't ask again". + const [guided, dispatchGuided] = useReducer(guidedReducer, GUIDED_IDLE); + const queryClient = useQueryClient(); + const { data: questionsData } = useQuery({ + queryKey: queryKeys.coachAboutMeQuestions(), + queryFn: async () => + apiGet<{ questions: string[] }>("/api/coach/about-me/questions"), + staleTime: 60_000, + }); + const pendingQuestions = questionsData?.questions ?? []; + const dismissQuestions = useMutation({ + mutationKey: queryKeys.coachAboutMeQuestions(), + mutationFn: async (question?: string) => + apiDelete<{ questions: string[] }>( + "/api/coach/about-me/questions", + question === undefined ? {} : { question }, + ), + onSuccess: (next) => { + queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); + }, + onError: () => { + toast.error(t("insights.coach.guided.dismissError")); + }, + }); + const { data: conversation } = useCoachConversation(currentConversationId); const send = useSendCoachMessage({ onDone: (resolvedId) => { @@ -143,44 +184,96 @@ export function CoachConversation({ send.cancel(); setCurrentConversationId(null); setInputValue(""); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); }); }, [registerReset, send, setInputValue]); async function handleSubmit(value: string) { const trimmed = value.trim(); if (!trimmed || send.isStreaming) return; - // v1.16.4 — when this message answers a tapped clarifying-question - // chip, peel the inserted question off the front so only the user's - // own words form the adoptable answer. An empty remainder (the user - // deleted their answer or sent the bare question) raises no offer. - const chipQuestion = activeChipQuestion; - setActiveChipQuestion(null); - const answer = chipQuestion - ? trimmed.startsWith(chipQuestion) - ? trimmed.slice(chipQuestion.length).trim() - : trimmed - : null; + // v1.16.5 — in the guided flow the composer message IS the answer + // to the current question. Mark it answered before the send so the + // question bubble anchors above the user's message, and dismiss the + // question server-side — answered questions never return. + const guidedQuestion = + guided.phase === "asking" ? guided.questions[guided.index] : null; + const guidedIndex = guided.phase === "asking" ? guided.index : null; + if (guidedQuestion !== null) { + dispatchGuided({ type: "ANSWER_SUBMITTED", answer: trimmed }); + dismissQuestions.mutate(guidedQuestion); + } setInputValue(""); await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, }); - if (chipQuestion && answer) { - setPendingAdopt({ question: chipQuestion, answer }); + if (guidedQuestion !== null && guidedIndex !== null) { + setPendingAdopt({ + question: guidedQuestion, + answer: trimmed, + guidedIndex, + }); + // The assistant turn is done — advance to the next question (or + // the closing summary). + dispatchGuided({ type: "TURN_COMPLETE" }); } } function handleNewChat() { setCurrentConversationId(null); setInputValue(""); - setActiveChipQuestion(null); setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); send.reset(); } const title = conversation?.title ?? t("insights.coach.newChat"); const tagline = t("insights.coach.tagline"); + // v1.16.5 — materialise the machine's thread items into bubbles. The + // thread only places them (`placeInterleaved`); every behaviour stays + // here with the flow. + const interleaved: InterleavedThreadItem[] = deriveThreadItems(guided).map( + (item) => ({ + key: item.key, + anchorAnswer: item.anchorAnswer, + node: + item.kind === "summary" ? ( + + ) : ( + dispatchGuided({ type: "SKIP" }) + : undefined + } + onLater={ + item.current + ? () => dispatchGuided({ type: "EXIT" }) + : undefined + } + onDismissRemaining={ + item.current + ? () => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "EXIT" }), + }) + : undefined + } + /> + ), + }), + ); + return (
      setCurrentConversationId(id)} + onSelect={(id) => { + // v1.16.5 — switching conversations drops the guided + // session; unanswered questions stay pending. + setCurrentConversationId(id); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); + }} /> ) : undefined } @@ -259,30 +358,47 @@ export function CoachConversation({ conversation={conversation ?? null} streaming={send.streaming} optimisticUser={send.optimisticUser} + interleaved={interleaved} /> } composer={
      - {/* v1.16.0 — pending self-context questions as tappable - chips. Tapping inserts the question into the composer - (the user answers it in their own words) and dismisses - the chip. Renders nothing when no questions pend. */} - {/* v1.16.4 — quiet adopt-into-self-context offer once a chip - question has been answered. Self-removes after settle. */} + {/* v1.16.4 — quiet adopt-into-self-context offer once a + clarifying question has been answered. Self-removes + after settle; v1.16.5 reports the outcome back into the + guided machine for the closing summary. */} {pendingAdopt && !send.isStreaming ? ( setPendingAdopt(null)} + onSettled={(adoption) => + dispatchGuided({ + type: "ADOPTION_SETTLED", + index: pendingAdopt.guidedIndex, + adoption, + }) + } + /> + ) : null} + {/* v1.16.5 — guided clarifying-questions entry card (V2 of + the v1.16.0 chips). Offers the in-chat sequence while + questions pend and the flow hasn't started. */} + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ type: "START", questions: pendingQuestions }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } /> ) : null} - { - setActiveChipQuestion(question); - setInputValue(`${question}\n`); - }} - />
      } @@ -311,6 +432,8 @@ export function CoachConversation({ onSelect={(id) => { setCurrentConversationId(id); setHistoryTrayOpen(false); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); }} /> } diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index be5fa2780..6d75fba81 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -59,6 +59,12 @@ export interface CoachInputProps { * already-open drawer we do not steal focus back. */ autoFocusOnOpen?: boolean; + /** + * v1.16.5 — placeholder override. The guided clarifying-questions + * flow swaps the generic "ask anything" prompt for an answer cue + * while a question is live. Falls back to the stock composer copy. + */ + placeholder?: string; } /** @@ -105,6 +111,7 @@ export function CoachInput({ isStreaming = false, inputId = "coach-composer-textarea", autoFocusOnOpen = false, + placeholder, }: CoachInputProps) { const { t } = useTranslations(); const textareaRef = useRef(null); @@ -202,7 +209,7 @@ export function CoachInput({ value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("insights.coach.composerPlaceholder")} + placeholder={placeholder ?? t("insights.coach.composerPlaceholder")} disabled={disabled} // v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard // return on iOS / Android instead of the generic newline diff --git a/src/components/insights/coach-panel/guided-dialog-bubbles.tsx b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx new file mode 100644 index 000000000..c9b6deb68 --- /dev/null +++ b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx @@ -0,0 +1,241 @@ +"use client"; + +/** + * v1.16.5 — in-thread bubbles for the guided clarifying-questions flow + * (see `guided-questions-machine.ts` for the state machine). + * + * Two bubbles, both deterministic (no model call) and both styled as a + * close sibling of the assistant bubble so the sequence reads as the + * Coach speaking — same avatar position and bubble geometry, but a + * purple-tinted surface and a question-mark avatar so a guided turn is + * visually distinct from AI prose: + * + * - `GuidedQuestionBubble` — one clarifying question with a + * "question 1 of 3" progress line + dot strip. The CURRENT question + * briefly shows the shared typing-dots indicator before the text + * reveals (the classic scripted-bot rhythm) and carries the quiet + * skip / later / don't-ask-again actions beneath the bubble. + * Answered questions re-render static (no actions, no reveal), + * anchored above the answer that resolved them. + * - `GuidedSummaryBubble` — the closing recap: how many questions + * were answered, how many answers were adopted into the + * self-context, and a deep-link to Settings → AI to review it. + */ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + ArrowRight, + ClipboardCheck, + MessageCircleQuestion, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +import { TypingDots } from "./message-thread"; + +/** Shared quiet text-button styling for the per-question actions. */ +const guidedActionClass = cn( + "text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs", + "hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none", + "disabled:opacity-60", +); + +function GuidedAvatar({ icon: Icon }: { icon: typeof ClipboardCheck }) { + return ( + + ); +} + +/** + * Dot strip mirroring the textual progress label: filled = asked + * (everything before the current question, answered or skipped), + * outlined = the current one, muted = still ahead. Decorative — the + * "question 1 of 3" text is the accessible progress signal. + */ +function ProgressDots({ current, total }: { current: number; total: number }) { + return ( +
      ); } @@ -593,8 +698,12 @@ function ChatBubble({ * with staggered delays so no custom keyframe is introduced; * `motion-reduce` freezes the dots and the `label` stays as the * screen-reader text either way. + * + * Exported since v1.16.5: the guided clarifying-question bubble replays + * the same indicator before a deterministic question reveals, so the + * scripted turns share one rhythm with the streamed ones. */ -function TypingDots({ label }: { label: string }) { +export function TypingDots({ label }: { label: string }) { return ( void; + /** + * v1.16.5 — reports the offer's outcome once, so the guided + * clarifying-questions flow can count adoptions for its closing + * summary. Fired on server settle (adopted / duplicate), on a failed + * request, and on an explicit decline — never on the auto-fold + * timeout (the outcome was already reported by then). + */ + onSettled?: ( + outcome: "adopted" | "duplicate" | "declined" | "failed", + ) => void; } export function SelfContextAdoptOffer({ question, answer, onDismiss, + onSettled, }: SelfContextAdoptOfferProps) { const { t } = useTranslations(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); @@ -44,6 +55,10 @@ export function SelfContextAdoptOffer({ }, onSuccess: (data) => { setSettled(data.adopted ? "adopted" : "duplicate"); + onSettled?.(data.adopted ? "adopted" : "duplicate"); + }, + onError: () => { + onSettled?.("failed"); }, }); @@ -108,7 +123,10 @@ export function SelfContextAdoptOffer({ type="button" aria-label={t("insights.coach.selfContextAdopt.dismiss")} data-slot="coach-self-context-adopt-dismiss" - onClick={onDismiss} + onClick={() => { + onSettled?.("declined"); + onDismiss(); + }} className={cn( "text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5", "hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none", diff --git a/src/components/insights/coach-panel/self-context-chips.tsx b/src/components/insights/coach-panel/self-context-chips.tsx deleted file mode 100644 index 6b77ac2c6..000000000 --- a/src/components/insights/coach-panel/self-context-chips.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -/** - * v1.16.0 — pending self-context questions as composer chips. - * - * After the user saves Settings → AI → "About me", the server derives - * up to 3 clarifying questions (`/api/coach/about-me/questions`). They - * surface here, directly above the Coach composer, as quiet tappable - * chips: tapping one inserts the question into the composer (so the - * user answers it in their own words) and dismisses it; the ✕ - * dismisses without inserting. No questions → nothing renders, zero - * vertical cost. - * - * Each chip is a `role="group"` of two sibling buttons (insert + - * dismiss) — buttons cannot nest, and both actions deserve their own - * focus stop. - */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { MessageCircleQuestion, X } from "lucide-react"; -import { toast } from "sonner"; - -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; - -export interface SelfContextChipsProps { - /** Insert the tapped question into the composer. */ - onPick: (question: string) => void; - /** Hidden while a reply streams so the input value cannot be yanked. */ - disabled?: boolean; -} - -export function SelfContextChips({ - onPick, - disabled = false, -}: SelfContextChipsProps) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - - const { data } = useQuery({ - queryKey: queryKeys.coachAboutMeQuestions(), - queryFn: async () => { - return apiGet<{ questions: string[] }>("/api/coach/about-me/questions"); - }, - staleTime: 60_000, - }); - - const dismiss = useMutation({ - mutationKey: queryKeys.coachAboutMeQuestions(), - mutationFn: async (question: string) => { - return apiDelete<{ questions: string[] }>("/api/coach/about-me/questions", { question }); - }, - onSuccess: (next) => { - queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); - }, - // v1.16.4 — a failed dismiss used to fail silently, leaving the chip - // in place with no signal that the request was rejected. - onError: () => { - toast.error(t("insights.coach.selfContextChipDismissError")); - }, - }); - - const questions = data?.questions ?? []; - if (questions.length === 0) return null; - - return ( -
      -

      -

      -
        - {questions.map((question) => ( -
      • - - -
      • - ))} -
      -
      - ); -} diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index fa3edba3a..4f2177208 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -7,23 +7,67 @@ * `notificationPrefs.coach.nudgesEnabled` through the roaming * notification-prefs blob so web + iOS share one source of truth. * + * v1.16.5 — per-group toggles (medication / vitals / routine) plus the + * frequency pref (weekly / biweekly) underneath the master switch. All + * of it rides the same `coach` sub-object of the prefs blob; the cron + * resolves it via `resolveCoachNudgePrefs`. + * * Mirrors `` (optimistic switch, auto-clearing inline - * status line). + * status line, NativeSelect for the enum pref). */ import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { MessageCircleHeart } from "lucide-react"; import { Switch } from "@/components/ui/switch"; +import { NativeSelect } from "@/components/ui/native-select"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -interface NotificationPrefsShape { - coach: { nudgesEnabled: boolean }; +interface CoachPrefsShape { + nudgesEnabled: boolean; + nudgeMedication: boolean; + nudgeVitals: boolean; + nudgeRoutine: boolean; + nudgeFrequency: "weekly" | "biweekly"; } +interface NotificationPrefsShape { + coach: CoachPrefsShape; +} + +const COACH_PREF_DEFAULTS: CoachPrefsShape = { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", +}; + +const GROUP_FIELDS = [ + "nudgeMedication", + "nudgeVitals", + "nudgeRoutine", +] as const; +type GroupField = (typeof GROUP_FIELDS)[number]; + +const GROUP_I18N: Record = { + nudgeMedication: { + label: "notifications.coachNudge.groupMedication", + desc: "notifications.coachNudge.groupMedicationDesc", + }, + nudgeVitals: { + label: "notifications.coachNudge.groupVitals", + desc: "notifications.coachNudge.groupVitalsDesc", + }, + nudgeRoutine: { + label: "notifications.coachNudge.groupRoutine", + desc: "notifications.coachNudge.groupRoutineDesc", + }, +}; + export function CoachNudgeCard({ isAuthenticated, }: { @@ -31,7 +75,7 @@ export function CoachNudgeCard({ }) { const { t } = useTranslations(); const queryClient = useQueryClient(); - const [optimistic, setOptimistic] = useState(null); + const [optimistic, setOptimistic] = useState>({}); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); @@ -66,10 +110,23 @@ export function CoachNudgeCard({ enabled: isAuthenticated, }); - const enabled = optimistic ?? prefs?.coach?.nudgesEnabled ?? true; + const resolved: CoachPrefsShape = { + ...COACH_PREF_DEFAULTS, + ...(prefs?.coach ?? {}), + ...optimistic, + }; + const enabled = resolved.nudgesEnabled; - async function handleToggle(next: boolean) { - setOptimistic(next); + /** + * One PATCH path for every knob on the card. `toast` carries the + * success line for the master toggle; the sub-prefs pass null and + * stay silent on success — the control itself shows the new state. + */ + async function patchCoach( + partial: Partial, + toast: string | null, + ) { + setOptimistic((prev) => ({ ...prev, ...partial })); setSaving(true); setMsg(null); setMsgType(null); @@ -81,23 +138,21 @@ export function CoachNudgeCard({ const res = await apiFetchRaw("/api/auth/me/notification-prefs", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ coach: { nudgesEnabled: next } }), + body: JSON.stringify({ coach: partial }), }); if (res.ok) { - setMsg( - next - ? t("notifications.coachNudge.enabledToast") - : t("notifications.coachNudge.disabledToast"), - ); - setMsgType("success"); + if (toast) { + setMsg(toast); + setMsgType("success"); + scheduleClear(); + } await queryClient.invalidateQueries({ queryKey: queryKeys.authNotificationPrefs(), }); - setOptimistic(null); - scheduleClear(); + setOptimistic({}); } else { - setOptimistic(null); + setOptimistic({}); setMsg(t("notifications.coachNudge.saveError")); setMsgType("error"); scheduleClear(); @@ -105,6 +160,15 @@ export function CoachNudgeCard({ setSaving(false); } + async function handleToggle(next: boolean) { + await patchCoach( + { nudgesEnabled: next }, + next + ? t("notifications.coachNudge.enabledToast") + : t("notifications.coachNudge.disabledToast"), + ); + } + return (
      } /> + {enabled && ( +
      +

      + {t("notifications.coachNudge.groupsLabel")} +

      + {GROUP_FIELDS.map((field) => ( +
      +
      +

      {t(GROUP_I18N[field].label)}

      +

      + {t(GROUP_I18N[field].desc)} +

      +
      + patchCoach({ [field]: next }, null)} + disabled={!isAuthenticated || saving} + aria-label={t(GROUP_I18N[field].label)} + /> +
      + ))} +
      + + + patchCoach( + { + nudgeFrequency: e.target.value as "weekly" | "biweekly", + }, + null, + ) + } + aria-label={t("notifications.coachNudge.frequencyAria")} + > + + + +
      +
      + )} {msg && (

      ({ + getAssistantFlags: vi.fn(async () => ({ coach: true })), +})); +vi.mock("@/lib/ai/provider", () => ({ + userRowHasProviderCredential: vi.fn(() => true), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn(() => "morning blood pressure"), + encryptToBytes: vi.fn(), +})); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { @@ -164,11 +185,154 @@ describe("buildCoachNudgePayload", () => { it("produces a distinct payload per trigger", () => { const titles = new Set( - (["compliance", "bp", "score", "selfContext"] as const).map( - (trigger) => buildCoachNudgePayload(trigger, "en").title, - ), + ( + [ + "compliance", + "bp", + "score", + "selfContext", + "weight", + "sleepDebt", + "measurementGap", + ] as const + ).map((trigger) => buildCoachNudgePayload(trigger, "en").title), ); - expect(titles.size).toBe(4); + expect(titles.size).toBe(7); + }); + + it("appends the personal focus suffix when a Coach focus exists", () => { + const { body } = buildCoachNudgePayload("bp", "en", "morning readings"); + expect(body).toContain("You wanted to keep an eye on: morning readings."); + }); + + it("clamps an over-long focus before quoting it", () => { + const focus = "x".repeat(COACH_NUDGE_FOCUS_MAX_CHARS + 40); + const { body } = buildCoachNudgePayload("weight", "en", focus); + expect(body).toContain("…"); + expect(body).not.toContain(focus); + }); + + it("skips the suffix for the self-context check-up nudge", () => { + const plain = buildCoachNudgePayload("selfContext", "en"); + const withFocus = buildCoachNudgePayload( + "selfContext", + "en", + "morning readings", + ); + expect(withFocus).toEqual(plain); + }); + + it("skips the suffix for a null / blank focus", () => { + const plain = buildCoachNudgePayload("bp", "en"); + expect(buildCoachNudgePayload("bp", "en", null)).toEqual(plain); + expect(buildCoachNudgePayload("bp", "en", " ")).toEqual(plain); + }); +}); + +describe("evaluateWeightTrigger", () => { + const range = { greenMin: 60, greenMax: 80 }; + + it("fires when the weekly mean sits outside the range and drifts away", () => { + expect( + evaluateWeightTrigger([82, 82.5, 83], [81, 81.3, 81.5], range), + ).toBe(true); + }); + + it("fires for a drift below the range too", () => { + expect(evaluateWeightTrigger([57, 57, 57], [59, 59, 59], range)).toBe( + true, + ); + }); + + it("stays silent inside the range", () => { + expect(evaluateWeightTrigger([79, 79, 79], [70, 70, 70], range)).toBe( + false, + ); + }); + + it("stays silent while converging back toward the range", () => { + expect(evaluateWeightTrigger([81, 81, 81], [83, 83, 83], range)).toBe( + false, + ); + }); + + it("stays silent under the drift floor", () => { + // 1.3 kg vs 1.0 kg from the range — only 0.3 kg of drift. + expect( + evaluateWeightTrigger([81.3, 81.3, 81.3], [81, 81, 81], range), + ).toBe(false); + }); + + it("requires enough readings in both windows", () => { + expect(evaluateWeightTrigger([85, 85], [81, 81, 81], range)).toBe(false); + expect(evaluateWeightTrigger([85, 85, 85], [81, 81], range)).toBe(false); + }); + + it("never fires without a resolvable range", () => { + expect(evaluateWeightTrigger([85, 85, 85], [81, 81, 81], null)).toBe( + false, + ); + }); +}); + +describe("evaluateSleepDebtTrigger", () => { + const floor = 7; // effective greenMin; deficit means < 6.5 h. + + it("fires when 4 of 5 recorded nights clearly undershoot the floor", () => { + expect( + evaluateSleepDebtTrigger([6, 6.2, 5.9, 6.4, 7.5], floor), + ).toBe(true); + }); + + it("stays silent with only 3 deficit nights", () => { + expect(evaluateSleepDebtTrigger([6, 6, 6, 7.5, 8], floor)).toBe(false); + }); + + it("does not count near-misses inside the margin as deficits", () => { + // 6.6 h against a 7 h floor is within the 0.5 h margin. + expect( + evaluateSleepDebtTrigger([6.6, 6.6, 6.6, 6.6, 6.6], floor), + ).toBe(false); + }); + + it("requires the minimum number of recorded nights", () => { + const nights = Array.from( + { length: COACH_NUDGE_SLEEP_MIN_NIGHTS - 1 }, + () => 5, + ); + expect(evaluateSleepDebtTrigger(nights, floor)).toBe(false); + }); + + it("never fires without a resolvable floor", () => { + expect(evaluateSleepDebtTrigger([5, 5, 5, 5, 5], null)).toBe(false); + }); +}); + +describe("evaluateMeasurementGapTrigger", () => { + it("fires when an active account goes fully silent", () => { + expect(evaluateMeasurementGapTrigger(12, 0)).toBe(true); + }); + + it("stays silent for a sporadically tracking account", () => { + expect(evaluateMeasurementGapTrigger(9, 0)).toBe(false); + }); + + it("stays silent while anything still arrives", () => { + expect(evaluateMeasurementGapTrigger(12, 1)).toBe(false); + }); +}); + +describe("COACH_NUDGE_TRIGGER_GROUPS", () => { + it("maps every trigger onto its pref group", () => { + expect(COACH_NUDGE_TRIGGER_GROUPS).toEqual({ + compliance: "medication", + bp: "vitals", + score: "vitals", + weight: "vitals", + sleepDebt: "vitals", + measurementGap: "routine", + selfContext: "routine", + }); }); }); @@ -238,3 +402,189 @@ describe("evaluateSelfContextTrigger", () => { ).toBe(false); }); }); + +/** + * v1.16.5 — tick-level contracts: gate order, per-group prefs gating, + * the per-user frequency cap, and the focus personalisation. The + * Prisma client is a hand-rolled mock; flags / provider / decrypt are + * module mocks (see the vi.mock calls at the top). + */ +describe("runCoachNudgeTick — gates and prefs", () => { + const now = new Date("2026-06-10T05:15:00Z"); + const MS_PER_DAY = 24 * 60 * 60 * 1000; + + function userRow(notificationPrefs: unknown) { + return { + id: "user-1", + locale: "en", + notificationPrefs, + heightCm: null, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + }; + } + + function prismaMock(overrides: { + users?: unknown[]; + intakeRows?: unknown[]; + recentNudge?: unknown; + coachFocusEncrypted?: Uint8Array | null; + }) { + return { + user: { + findMany: vi.fn(async () => overrides.users ?? []), + }, + appSettings: { + findUnique: vi.fn(async () => ({ adminAiKeyEncrypted: "k" })), + }, + pushAttempt: { + findFirst: vi.fn(async (args?: unknown) => { + void args; + return overrides.recentNudge ?? null; + }), + }, + medicationIntakeEvent: { + findMany: vi.fn(async () => overrides.intakeRows ?? []), + }, + measurement: { + findMany: vi.fn(async () => []), + count: vi.fn(async () => 1), + }, + coachUsage: { findFirst: vi.fn(async () => null) }, + userHealthProfile: { + findUnique: vi.fn(async () => ({ + coachFocusEncrypted: overrides.coachFocusEncrypted ?? null, + })), + }, + $queryRaw: vi.fn(async () => [{ days: 0 }]), + }; + } + + /** Six resolved doses, one taken → 17 %, well under the 60 % gate. */ + const failingIntakes = [ + { takenAt: new Date(), skipped: false, autoMissed: false }, + ...Array.from({ length: 5 }, () => ({ + takenAt: null, + skipped: false, + autoMissed: true, + })), + ]; + + beforeEach(() => { + vi.mocked(getAssistantFlags).mockResolvedValue({ + coach: true, + } as Awaited>); + vi.mocked(userRowHasProviderCredential).mockReturnValue(true); + }); + + it("counts a master opt-out before touching provider or cap gates", async () => { + const prisma = prismaMock({ + users: [userRow({ coach: { nudgesEnabled: false } })], + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedOptedOut).toBe(1); + expect(userRowHasProviderCredential).not.toHaveBeenCalled(); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + }); + + it("treats every group disabled as opted out, not as no-trigger", async () => { + const prisma = prismaMock({ + users: [ + userRow({ + coach: { + nudgesEnabled: true, + nudgeMedication: false, + nudgeVitals: false, + nudgeRoutine: false, + }, + }), + ], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedOptedOut).toBe(1); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); + + it("checks the provider gate before the frequency cap", async () => { + vi.mocked(userRowHasProviderCredential).mockReturnValue(false); + const prisma = prismaMock({ users: [userRow(null)] }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedNoProvider).toBe(1); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + }); + + it("widens the cap cutoff to 14 days for a biweekly user", async () => { + const prisma = prismaMock({ + users: [userRow({ coach: { nudgeFrequency: "biweekly" } })], + }); + await runCoachNudgeTick(prisma as unknown as PrismaClient, now, { + dispatch: vi.fn(), + }); + const arg = vi.mocked(prisma.pushAttempt.findFirst).mock.calls[0]?.[0] as { + where: { createdAt: { gte: Date } }; + } | undefined; + expect(arg?.where.createdAt.gte.getTime()).toBe( + now.getTime() - 14 * MS_PER_DAY, + ); + }); + + it("skips a disabled group's trigger queries entirely", async () => { + const prisma = prismaMock({ + users: [ + userRow({ + coach: { nudgeMedication: false }, + }), + ], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + // The would-be compliance hit is never even queried; the remaining + // groups find nothing in the empty fixtures. + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(summary.skippedNoTrigger).toBe(1); + }); + + it("personalises the dispatched body with the decrypted Coach focus", async () => { + const dispatch = vi.fn(async (input: unknown) => { + void input; + return { dispatched: true }; + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + coachFocusEncrypted: new Uint8Array([1, 2, 3]), + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never }, + ); + expect(summary.dispatched).toBe(1); + const payload = dispatch.mock.calls[0]?.[0] as unknown as { + message: string; + metadata: { trigger: string }; + }; + expect(payload.metadata.trigger).toBe("compliance"); + expect(payload.message).toContain( + "You wanted to keep an eye on: morning blood pressure.", + ); + }); +}); diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 6f29785d3..5b4c4281a 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -17,11 +17,14 @@ * the operator's shared key) — a nudge into a Coach that cannot * answer is worse than silence. * 4. Per-user opt-out: `notificationPrefs.coach.nudgesEnabled` - * (default ON; Settings → Notifications). - * 5. Frequency cap: at most one nudge per rolling 7 days, anchored - * on the `push_attempts` ledger (`eventType = COACH_NUDGE`, + * (default ON; Settings → Notifications). v1.16.5 adds per-group + * toggles underneath (medication / vitals / routine) — a disabled + * group's triggers are never evaluated for that user. + * 5. Frequency cap: at most one nudge per rolling window — 7 days by + * default, 14 when the user picked "biweekly" — anchored on the + * `push_attempts` ledger (`eventType = COACH_NUDGE`, * `result = "ok"`) — no new table, 90-day retention dwarfs the - * 7-day lookback, and a failed dispatch leaves the slot free. + * 14-day lookback, and a failed dispatch leaves the slot free. * * Triggers (first hit wins, evaluated cheapest-first): * - `compliance`: 7-day medication adherence < 60 % with at least @@ -36,7 +39,29 @@ * incomplete or 60+ days stale AND the user actively talks to the * Coach (a CoachUsage row in the last 14 days) — a gentle check-up * nudge to refresh the personal context the Coach reads. Presence - * is checked on the encrypted columns only; nothing is decrypted. + * is checked on the encrypted columns only; nothing is decrypted + * during trigger evaluation. + * - `weight` (v1.16.5): the weekly weight mean sits outside the + * user's effective green range AND has drifted further away from + * it than the previous week's mean (≥ 0.5 kg) — trend drift, not a + * single outlier reading. + * - `sleepDebt` (v1.16.5): at least 4 of the last 7 nights fall + * clearly (0.5 h margin) under the user's effective sleep floor, + * with at least 5 recorded nights so a sparse week stays silent. + * - `measurementGap` (v1.16.5): a previously active account + * (measurements on ≥ 10 distinct days in the preceding 3 weeks) + * records NOTHING for 7 straight days — an abrupt stop, whether + * disengagement or a silently broken sync, is worth a check-in. + * + * Trigger groups (v1.16.5, per-group opt-outs in the prefs blob): + * medication → compliance; vitals → bp / score / weight / sleepDebt; + * routine → measurementGap / selfContext. + * + * Personalisation (v1.16.5): when the user wrote a Coach focus in the + * self-context questionnaire, the nudge body appends a deterministic + * reference to it ("you wanted to keep an eye on …"). The focus is + * decrypted only AFTER a trigger fired and a nudge is actually about + * to dispatch — fail-closed, a decrypt error just drops the suffix. */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; @@ -46,7 +71,8 @@ import { type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import { resolveCoachNudgesEnabled } from "@/lib/validations/notification-prefs"; +import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; @@ -59,7 +85,12 @@ export const COACH_NUDGE_QUEUE = "coach-nudge"; /** 05:15 Europe/Berlin — after the 04:45–04:55 score crons settled. */ export const COACH_NUDGE_CRON = "15 5 * * *"; -/** Frequency cap: one nudge per user per rolling week. */ +/** + * Frequency cap default: one nudge per user per rolling week. v1.16.5 — + * the per-user `nudgeFrequency` pref ("weekly" | "biweekly") can widen + * this to 14 days; the constant remains the default and the unit-test + * anchor. + */ export const COACH_NUDGE_MIN_INTERVAL_DAYS = 7; /** Compliance trigger: 7-day rate below this fires (0..1). */ @@ -76,8 +107,49 @@ export const COACH_NUDGE_SCORE_MIN_SAMPLES = 3; export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; /** Self-context trigger: Coach usage within this window counts as active. */ export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; +/** Weight trigger: minimum readings per weekly window. */ +export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; +/** Sleep trigger: minimum recorded nights in the 7-day window. */ +export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; +/** Sleep trigger: nights under the floor required to fire. */ +export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; +/** Gap trigger: distinct active days required in the prior 3 weeks. */ +export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10; +/** Gap trigger: length of the silent window that fires (days). */ +export const COACH_NUDGE_GAP_SILENT_DAYS = 7; +/** Gap trigger: activity lookback BEFORE the silent window (days). */ +export const COACH_NUDGE_GAP_LOOKBACK_DAYS = 21; +/** Personalisation: max focus characters quoted in the nudge body. */ +export const COACH_NUDGE_FOCUS_MAX_CHARS = 80; -export type CoachNudgeTrigger = "compliance" | "bp" | "score" | "selfContext"; +export type CoachNudgeTrigger = + | "compliance" + | "bp" + | "score" + | "selfContext" + | "weight" + | "sleepDebt" + | "measurementGap"; + +/** v1.16.5 — per-group opt-outs map triggers onto three pref toggles. */ +export type CoachNudgeTriggerGroup = "medication" | "vitals" | "routine"; + +export const COACH_NUDGE_TRIGGER_GROUPS: Record< + CoachNudgeTrigger, + CoachNudgeTriggerGroup +> = { + compliance: "medication", + bp: "vitals", + score: "vitals", + weight: "vitals", + sleepDebt: "vitals", + measurementGap: "routine", + selfContext: "routine", +}; export interface CoachNudgeSummary { candidatesScanned: number; @@ -192,43 +264,159 @@ export function evaluateScoreTrigger( return mean(priorValues) - mean(recentValues) >= COACH_NUDGE_SCORE_DROP; } +/** + * v1.16.5 — weight trend drift against the user's effective green + * range. Two conditions, both required, so a single heavy breakfast + * never nudges: + * 1. the recent 7-day mean sits OUTSIDE the green range, and + * 2. it has drifted at least `COACH_NUDGE_WEIGHT_DRIFT_KG` further + * away from the range than the prior week's mean — direction-aware + * via distance-to-range, so it fires for moving away on either + * side and stays silent while the user converges back. + * Both windows need `COACH_NUDGE_WEIGHT_MIN_READINGS` readings; no + * resolvable range (no height, no override) never fires. + */ +export function evaluateWeightTrigger( + recentValues: number[], + priorValues: number[], + range: { greenMin: number; greenMax: number } | null, +): boolean { + if (range === null) return false; + if ( + recentValues.length < COACH_NUDGE_WEIGHT_MIN_READINGS || + priorValues.length < COACH_NUDGE_WEIGHT_MIN_READINGS + ) { + return false; + } + const mean = (values: number[]) => + values.reduce((sum, v) => sum + v, 0) / values.length; + const distance = (v: number) => + Math.max(0, range.greenMin - v, v - range.greenMax); + const recentDistance = distance(mean(recentValues)); + const priorDistance = distance(mean(priorValues)); + return ( + recentDistance > 0 && + recentDistance - priorDistance >= COACH_NUDGE_WEIGHT_DRIFT_KG + ); +} + +/** + * v1.16.5 — sleep-deficit series. Fires when at least + * `COACH_NUDGE_SLEEP_DEFICIT_NIGHTS` of the recorded nights undershoot + * the effective sleep floor by a clear margin (0.5 h — a 6:55 night + * against a 7 h floor is not a deficit pattern). Requires at least + * `COACH_NUDGE_SLEEP_MIN_NIGHTS` recorded nights in the 7-day window so + * a sparsely tracked week stays silent; no resolvable floor never fires. + */ +export function evaluateSleepDebtTrigger( + nightlyHours: number[], + greenMin: number | null, +): boolean { + if (greenMin === null) return false; + if (nightlyHours.length < COACH_NUDGE_SLEEP_MIN_NIGHTS) return false; + const deficits = nightlyHours.filter( + (h) => h < greenMin - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, + ).length; + return deficits >= COACH_NUDGE_SLEEP_DEFICIT_NIGHTS; +} + +/** + * v1.16.5 — measurement-gap series: a previously ACTIVE account that + * abruptly stops recording anything. Active means measurements on at + * least `COACH_NUDGE_GAP_MIN_ACTIVE_DAYS` distinct days across the + * three weeks BEFORE the silent window; the trigger fires only when the + * last `COACH_NUDGE_GAP_SILENT_DAYS` days hold zero measurements. A + * sporadically tracking account never qualifies as "active", so it is + * never nagged for being itself. + */ +export function evaluateMeasurementGapTrigger( + priorActiveDays: number, + recentMeasurementCount: number, +): boolean { + return ( + priorActiveDays >= COACH_NUDGE_GAP_MIN_ACTIVE_DAYS && + recentMeasurementCount === 0 + ); +} + /** * Build the localised push payload for a trigger. Bodies stay * deliberately vague on numbers — a lock screen is not the place for * health figures; the Coach conversation carries the detail. + * + * v1.16.5 — `coachFocus` (the user's own "watch this" line from the + * self-context questionnaire, already decrypted by the caller) appends + * a deterministic personal suffix to the body. The focus is the user's + * own words about what to pay attention to — not a reading, not a + * figure — so quoting it on the lock screen stays within the + * no-health-figures rule. Clamped to `COACH_NUDGE_FOCUS_MAX_CHARS`. + * The self-context check-up nudge skips the suffix: quoting the focus + * while asking the user to refresh it would contradict itself. */ export function buildCoachNudgePayload( trigger: CoachNudgeTrigger, locale: string | null | undefined, + coachFocus?: string | null, ): { title: string; body: string } { const t = getServerTranslator(resolveLocale(locale)).t; - switch (trigger) { - case "compliance": - return { - title: t("coachNudges.complianceTitle"), - body: t("coachNudges.complianceBody"), - }; - case "bp": - return { - title: t("coachNudges.bpTitle"), - body: t("coachNudges.bpBody"), - }; - case "score": - return { - title: t("coachNudges.scoreTitle"), - body: t("coachNudges.scoreBody"), - }; - case "selfContext": - return { - title: t("coachNudges.selfContextTitle"), - body: t("coachNudges.selfContextBody"), - }; - } + const base = ((): { title: string; body: string } => { + switch (trigger) { + case "compliance": + return { + title: t("coachNudges.complianceTitle"), + body: t("coachNudges.complianceBody"), + }; + case "bp": + return { + title: t("coachNudges.bpTitle"), + body: t("coachNudges.bpBody"), + }; + case "score": + return { + title: t("coachNudges.scoreTitle"), + body: t("coachNudges.scoreBody"), + }; + case "selfContext": + return { + title: t("coachNudges.selfContextTitle"), + body: t("coachNudges.selfContextBody"), + }; + case "weight": + return { + title: t("coachNudges.weightTitle"), + body: t("coachNudges.weightBody"), + }; + case "sleepDebt": + return { + title: t("coachNudges.sleepDebtTitle"), + body: t("coachNudges.sleepDebtBody"), + }; + case "measurementGap": + return { + title: t("coachNudges.measurementGapTitle"), + body: t("coachNudges.measurementGapBody"), + }; + } + })(); + + const focus = coachFocus?.trim(); + if (!focus || trigger === "selfContext") return base; + const clamped = + focus.length > COACH_NUDGE_FOCUS_MAX_CHARS + ? `${focus.slice(0, COACH_NUDGE_FOCUS_MAX_CHARS - 1).trimEnd()}…` + : focus; + return { + title: base.title, + body: `${base.body} ${t("coachNudges.focusSuffix", { focus: clamped })}`, + }; } /** * Evaluate the triggers for one user. Exported for tests; the tick - * below feeds it the pre-fetched rows. + * below feeds it the pre-fetched rows. v1.16.5 — `groups` carries the + * per-group opt-outs from the prefs blob: a disabled group's triggers + * are skipped entirely (no queries, no evaluation). Defaults to + * all-enabled so existing callers keep the old behaviour. */ export async function findTriggerForUser( prisma: PrismaClient, @@ -240,109 +428,216 @@ export async function findTriggerForUser( thresholdsJson: unknown; }, now: Date, + groups: Record = { + medication: true, + vitals: true, + routine: true, + }, ): Promise { const sevenDaysAgo = new Date(now.getTime() - 7 * MS_PER_DAY); const fourteenDaysAgo = new Date(now.getTime() - 14 * MS_PER_DAY); - // 1) Medication compliance (7 d). `autoMissed` rides along so the - // trigger can restrict its denominator to resolved slots — a - // still-open pending is not a miss yet. - const intakeRows = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - deletedAt: null, - scheduledFor: { gte: sevenDaysAgo, lte: now }, - }, - select: { takenAt: true, skipped: true, autoMissed: true }, - }); - if (evaluateComplianceTrigger(intakeRows)) return "compliance"; - - // 2) Systolic weekly mean vs the user's effective target. - const systolic = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "BLOOD_PRESSURE_SYS", - deletedAt: null, - measuredAt: { gte: sevenDaysAgo, lte: now }, - }, - select: { value: true }, - }); - if (systolic.length >= COACH_NUDGE_BP_MIN_READINGS) { - const range = getEffectiveRange( - "BLOOD_PRESSURE_SYS", + const effectiveRange = ( + metric: "BLOOD_PRESSURE_SYS" | "WEIGHT" | "SLEEP_DURATION", + ) => + getEffectiveRange( + metric, { heightCm: user.heightCm, dateOfBirth: user.dateOfBirth, gender: user.gender, }, (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, - ); - if ( - evaluateBpTrigger( - systolic.map((m) => m.value), - range.range?.greenMax ?? null, - ) - ) { - return "bp"; + ).range; + + // ── medication group ─────────────────────────────────────────────── + if (groups.medication) { + // 1) Medication compliance (7 d). `autoMissed` rides along so the + // trigger can restrict its denominator to resolved slots — a + // still-open pending is not a miss yet. + const intakeRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + deletedAt: null, + scheduledFor: { gte: sevenDaysAgo, lte: now }, + }, + select: { takenAt: true, skipped: true, autoMissed: true }, + }); + if (evaluateComplianceTrigger(intakeRows)) return "compliance"; + } + + // ── vitals group ─────────────────────────────────────────────────── + if (groups.vitals) { + // 2) Systolic weekly mean vs the user's effective target. + const systolic = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, + }, + select: { value: true }, + }); + if (systolic.length >= COACH_NUDGE_BP_MIN_READINGS) { + if ( + evaluateBpTrigger( + systolic.map((m) => m.value), + effectiveRange("BLOOD_PRESSURE_SYS")?.greenMax ?? null, + ) + ) { + return "bp"; + } + } + + // 3) Recovery score falling sharply week-over-week. + const scores = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "RECOVERY_SCORE", + deletedAt: null, + measuredAt: { gte: fourteenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + const recent = scores + .filter((m) => m.measuredAt >= sevenDaysAgo) + .map((m) => m.value); + const prior = scores + .filter((m) => m.measuredAt < sevenDaysAgo) + .map((m) => m.value); + if (evaluateScoreTrigger(recent, prior)) return "score"; + + // 4) Weight weekly mean drifting away from the effective range. + const weightRange = effectiveRange("WEIGHT"); + if (weightRange) { + const weights = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "WEIGHT", + deletedAt: null, + measuredAt: { gte: fourteenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + const recentWeights = weights + .filter((m) => m.measuredAt >= sevenDaysAgo) + .map((m) => m.value); + const priorWeights = weights + .filter((m) => m.measuredAt < sevenDaysAgo) + .map((m) => m.value); + if ( + evaluateWeightTrigger(recentWeights, priorWeights, { + greenMin: weightRange.greenMin, + greenMax: weightRange.greenMax, + }) + ) { + return "weight"; + } + } + + // 5) Sleep-deficit series: per-night hours over the last 7 days, + // longest record per calendar night (multi-source rows collapse + // to the fullest one). + const sleepRows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + if (sleepRows.length > 0) { + const byNight = new Map(); + for (const row of sleepRows) { + const night = row.measuredAt.toISOString().slice(0, 10); + const prev = byNight.get(night); + if (prev === undefined || row.value > prev) { + byNight.set(night, row.value); + } + } + if ( + evaluateSleepDebtTrigger( + [...byNight.values()], + effectiveRange("SLEEP_DURATION")?.greenMin ?? null, + ) + ) { + return "sleepDebt"; + } } } - // 3) Recovery score falling sharply week-over-week. - const scores = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "RECOVERY_SCORE", - deletedAt: null, - measuredAt: { gte: fourteenDaysAgo, lte: now }, - }, - select: { value: true, measuredAt: true }, - }); - const recent = scores - .filter((m) => m.measuredAt >= sevenDaysAgo) - .map((m) => m.value); - const prior = scores - .filter((m) => m.measuredAt < sevenDaysAgo) - .map((m) => m.value); - if (evaluateScoreTrigger(recent, prior)) return "score"; - - // 4) Self-context incomplete / stale while the Coach is in active - // use. Presence-only reads on the encrypted columns — nothing is - // decrypted in this job. - const coachActiveCutoff = new Date( - now.getTime() - COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY, - ); - const recentUsage = await prisma.coachUsage.findFirst({ - where: { userId: user.id, updatedAt: { gte: coachActiveCutoff } }, - orderBy: { updatedAt: "desc" }, - select: { updatedAt: true }, - }); - if (recentUsage) { - const profileRow = await prisma.userHealthProfile.findUnique({ - where: { userId: user.id }, - select: { - aboutMeEncrypted: true, - conditionsEncrypted: true, - allergiesEncrypted: true, - coachFocusEncrypted: true, - updatedAt: true, + // ── routine group ────────────────────────────────────────────────── + if (groups.routine) { + // 6) Measurement gap: an active account going silent for a week. + // The recent count is cheap; the distinct-day count over the + // prior three weeks runs as one aggregate instead of pulling + // every row (dense intraday sources would make that thousands). + const recentCount = await prisma.measurement.count({ + where: { + userId: user.id, + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, }, }); - const triggered = evaluateSelfContextTrigger( - { - profile: profileRow - ? { - hasAboutMe: profileRow.aboutMeEncrypted !== null, - hasConditions: profileRow.conditionsEncrypted !== null, - hasAllergies: profileRow.allergiesEncrypted !== null, - hasCoachFocus: profileRow.coachFocusEncrypted !== null, - updatedAt: profileRow.updatedAt, - } - : null, - lastCoachUseAt: recentUsage.updatedAt, - }, - now, + if (recentCount === 0) { + const gapWindowStart = new Date( + sevenDaysAgo.getTime() - COACH_NUDGE_GAP_LOOKBACK_DAYS * MS_PER_DAY, + ); + const activeDayRows = await prisma.$queryRaw<{ days: number }[]>` + SELECT COUNT(DISTINCT (measured_at AT TIME ZONE 'UTC')::date)::int AS days + FROM measurements + WHERE user_id = ${user.id} + AND deleted_at IS NULL + AND measured_at >= ${gapWindowStart} + AND measured_at < ${sevenDaysAgo} + `; + if ( + evaluateMeasurementGapTrigger(activeDayRows[0]?.days ?? 0, recentCount) + ) { + return "measurementGap"; + } + } + + // 7) Self-context incomplete / stale while the Coach is in active + // use. Presence-only reads on the encrypted columns — nothing is + // decrypted during trigger evaluation. + const coachActiveCutoff = new Date( + now.getTime() - COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY, ); - if (triggered) return "selfContext"; + const recentUsage = await prisma.coachUsage.findFirst({ + where: { userId: user.id, updatedAt: { gte: coachActiveCutoff } }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }); + if (recentUsage) { + const profileRow = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { + aboutMeEncrypted: true, + conditionsEncrypted: true, + allergiesEncrypted: true, + coachFocusEncrypted: true, + updatedAt: true, + }, + }); + const triggered = evaluateSelfContextTrigger( + { + profile: profileRow + ? { + hasAboutMe: profileRow.aboutMeEncrypted !== null, + hasConditions: profileRow.conditionsEncrypted !== null, + hasAllergies: profileRow.allergiesEncrypted !== null, + hasCoachFocus: profileRow.coachFocusEncrypted !== null, + updatedAt: profileRow.updatedAt, + } + : null, + lastCoachUseAt: recentUsage.updatedAt, + }, + now, + ); + if (triggered) return "selfContext"; + } } return null; @@ -407,15 +702,19 @@ export async function runCoachNudgeTick( }); const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - const capCutoff = new Date( - now.getTime() - COACH_NUDGE_MIN_INTERVAL_DAYS * MS_PER_DAY, - ); - for (const user of users) { summary.candidatesScanned += 1; try { - // Gate 4 — per-user opt-out. - if (!resolveCoachNudgesEnabled(user.notificationPrefs)) { + // Gate 4 — per-user opt-out: master switch plus the v1.16.5 + // per-group toggles. A user with the master ON but every group + // OFF counts as opted out, not as "no trigger". + const nudgePrefs = resolveCoachNudgePrefs(user.notificationPrefs); + if ( + !nudgePrefs.enabled || + (!nudgePrefs.groups.medication && + !nudgePrefs.groups.vitals && + !nudgePrefs.groups.routine) + ) { summary.skippedOptedOut += 1; continue; } @@ -426,10 +725,14 @@ export async function runCoachNudgeTick( continue; } - // Gate 5 — one nudge per rolling week, anchored on the - // push-attempts ledger. A delivered nudge writes an `ok` row per - // succeeding channel; a fully failed dispatch leaves the slot - // free so tomorrow's tick can retry. + // Gate 5 — one nudge per rolling window (7 d default, 14 d when + // the user picked "biweekly"), anchored on the push-attempts + // ledger. A delivered nudge writes an `ok` row per succeeding + // channel; a fully failed dispatch leaves the slot free so + // tomorrow's tick can retry. + const capCutoff = new Date( + now.getTime() - nudgePrefs.minIntervalDays * MS_PER_DAY, + ); const recentNudge = await prisma.pushAttempt.findFirst({ where: { userId: user.id, @@ -444,13 +747,41 @@ export async function runCoachNudgeTick( continue; } - const trigger = await findTriggerForUser(prisma, user, now); + const trigger = await findTriggerForUser( + prisma, + user, + now, + nudgePrefs.groups, + ); if (!trigger) { summary.skippedNoTrigger += 1; continue; } - const { title, body } = buildCoachNudgePayload(trigger, user.locale); + // Personalisation — decrypt the user's Coach focus only now that + // a nudge is actually about to dispatch. Fail-closed: a missing + // row or an undecryptable payload just drops the suffix. + let coachFocus: string | null = null; + if (trigger !== "selfContext") { + try { + const profileRow = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { coachFocusEncrypted: true }, + }); + if (profileRow?.coachFocusEncrypted) { + coachFocus = + decryptFromBytes(profileRow.coachFocusEncrypted).trim() || null; + } + } catch { + coachFocus = null; + } + } + + const { title, body } = buildCoachNudgePayload( + trigger, + user.locale, + coachFocus, + ); const outcome = await dispatchImpl({ eventType: "COACH_NUDGE", userId: user.id, diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index fdc570eb3..efaf6f12a 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -7,6 +7,7 @@ import { isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, + resolveCoachNudgePrefs, resolveDeviceDelivery, resolveMoodReminderHour, resolveNotificationPrefs, @@ -91,7 +92,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -100,7 +107,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -111,7 +124,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -138,7 +157,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -153,7 +178,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -165,7 +196,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -177,7 +214,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -190,7 +233,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 8 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -335,3 +384,58 @@ describe("resolveDeviceDelivery — per-device override", () => { ).toBe(false); }); }); + +describe("resolveCoachNudgePrefs — v1.16.5 nudge cron gate", () => { + it("resolves the documented defaults for a null row", () => { + expect(resolveCoachNudgePrefs(null)).toEqual({ + enabled: true, + groups: { medication: true, vitals: true, routine: true }, + minIntervalDays: 7, + }); + }); + + it("keeps legacy rows (master switch only) fully enabled", () => { + const out = resolveCoachNudgePrefs({ coach: { nudgesEnabled: true } }); + expect(out.groups).toEqual({ + medication: true, + vitals: true, + routine: true, + }); + expect(out.minIntervalDays).toBe(7); + }); + + it("carries the master opt-out", () => { + expect( + resolveCoachNudgePrefs({ coach: { nudgesEnabled: false } }).enabled, + ).toBe(false); + }); + + it("resolves per-group opt-outs independently", () => { + const out = resolveCoachNudgePrefs({ + coach: { nudgeMedication: false, nudgeRoutine: false }, + }); + expect(out.groups).toEqual({ + medication: false, + vitals: true, + routine: false, + }); + // Group opt-outs do not touch the master switch. + expect(out.enabled).toBe(true); + }); + + it("maps the biweekly frequency pref onto 14 days", () => { + expect( + resolveCoachNudgePrefs({ coach: { nudgeFrequency: "biweekly" } }) + .minIntervalDays, + ).toBe(14); + }); + + it("rejects an unknown frequency by falling back to defaults", () => { + // A drifted enum value fails the schema; the whole blob resolves + // to the documented defaults rather than throwing in the cron. + expect( + resolveCoachNudgePrefs({ coach: { nudgeFrequency: "daily" } }) + .minIntervalDays, + ).toBe(7); + }); +}); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 6da77ea66..130b1ee1a 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -81,6 +81,18 @@ const cyclePrefsSchema = z const coachPrefsSchema = z .object({ nudgesEnabled: z.boolean(), + /** + * v1.16.5 — per-trigger-group opt-outs underneath the master + * switch. Groups, not individual triggers, so the UI stays three + * comprehensible toggles as the trigger list grows: + * medication → compliance; vitals → bp / score / weight / sleep; + * routine → measurement-gap / self-context check-up. + */ + nudgeMedication: z.boolean(), + nudgeVitals: z.boolean(), + nudgeRoutine: z.boolean(), + /** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */ + nudgeFrequency: z.enum(["weekly", "biweekly"]), }) .partial(); @@ -137,6 +149,14 @@ export interface NotificationPrefs { * 05:15 nudge cron skips the user when `false`. */ nudgesEnabled: boolean; + /** v1.16.5 — medication-group triggers (compliance). Default on. */ + nudgeMedication: boolean; + /** v1.16.5 — vitals-group triggers (bp / score / weight / sleep). */ + nudgeVitals: boolean; + /** v1.16.5 — routine-group triggers (measurement gap / self-context). */ + nudgeRoutine: boolean; + /** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */ + nudgeFrequency: "weekly" | "biweekly"; }; } @@ -165,6 +185,10 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { }, coach: { nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", }, }; @@ -302,6 +326,36 @@ export function resolveCoachNudgesEnabled(raw: unknown): boolean { return parseNotificationPrefs(raw).coach.nudgesEnabled; } +/** + * v1.16.5 — fully-resolved Coach-nudge view for the cron: the master + * switch, the three per-group toggles, and the frequency cap mapped to + * days. One call so the nudge tick reads a single shape instead of + * threading the whole prefs object. + */ +export interface CoachNudgePrefs { + enabled: boolean; + groups: { + medication: boolean; + vitals: boolean; + routine: boolean; + }; + /** 7 for "weekly", 14 for "biweekly". */ + minIntervalDays: number; +} + +export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs { + const coach = parseNotificationPrefs(raw).coach; + return { + enabled: coach.nudgesEnabled, + groups: { + medication: coach.nudgeMedication, + vitals: coach.nudgeVitals, + routine: coach.nudgeRoutine, + }, + minIntervalDays: coach.nudgeFrequency === "biweekly" ? 14 : 7, + }; +} + function cloneDefaults(): NotificationPrefs { return { medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, diff --git a/src/lib/validations/schedule-revision.ts b/src/lib/validations/schedule-revision.ts new file mode 100644 index 000000000..a6d7f8bf2 --- /dev/null +++ b/src/lib/validations/schedule-revision.ts @@ -0,0 +1,62 @@ +/** + * v1.16.5 — request schema for + * `POST /api/medications/{id}/schedule-revisions`. + * + * Lives outside the route file so the OpenAPI registry can import it + * without dragging `@/lib/db` into the generator script — the same + * split as `src/lib/validations/invite.ts`. + * + * The endpoint appends a MANUAL schedule era ("the medication dosed at + * 07:00/19:00 from March to June, before I tracked it here"). The body + * carries the era bounds as instants plus the daily times-of-day the + * era dosed at; the route derives the snapshot payload entry + * (`FREQ=DAILY`, window pulled to min/max of the times) so the era + * minter reads it exactly like a write-path archive. + */ +import { z } from "zod/v4"; + +/** HH:mm wall-clock literal — mirrors `validations/medication.ts`. */ +const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export const scheduleRevisionCreateSchema = z + .object({ + /** Inclusive start instant of the manual era. */ + validFrom: z.iso + .datetime({ offset: true }) + .describe("Inclusive start instant of the manual era (ISO 8601)."), + /** + * Exclusive end instant — the moment the next plan took over. Must + * lie after `validFrom` and at or before the start of the live + * schedule era (the newest archived revision's `validUntil`, or + * "now" when no revision exists yet). + */ + validUntil: z.iso + .datetime({ offset: true }) + .describe( + "Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.", + ), + /** Daily dose times the era ran at. Same cap as live schedules. */ + timesOfDay: z + .array(z.string().regex(timeRegex, "Format: HH:mm")) + .min(1) + .max(8) + .describe( + "Daily dose times (HH:mm, user local) the era ran at. 1–8 entries.", + ), + }) + .refine( + (body) => new Date(body.validFrom).getTime() < new Date(body.validUntil).getTime(), + { + message: "validFrom must lie before validUntil", + path: ["validUntil"], + }, + ) + .meta({ + id: "CreateScheduleRevisionRequest", + description: + "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era.", + }); + +export type ScheduleRevisionCreateInput = z.infer< + typeof scheduleRevisionCreateSchema +>; From 5639da43333102f10731c3a0e68cf5e3e915249f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH 3/9] feat(medications,admin): the schedule's history is visible and invites count their uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule tab shows its eras as a collapsible timeline — current plan since its date, predecessors with their times and span — and a past era can be added manually for old history, with validation against overlaps and the live plan. Invite creation chooses one to fifty uses and the redemption list scrolls. --- docs/api/openapi.yaml | 268 +++++++++ .../migration.sql | 19 + prisma/schema.prisma | 5 + .../schedule-revisions/[revisionId]/route.ts | 74 +++ .../__tests__/route.test.ts | 328 +++++++++++ .../[id]/schedule-revisions/route.ts | 263 +++++++++ .../detail/MedicationDetailTabs.tsx | 19 + .../schedule-history-timeline.test.tsx | 180 ++++++ .../scheduling/schedule-history-timeline.tsx | 530 ++++++++++++++++++ src/lib/openapi/routes/medications.ts | 152 +++++ src/lib/query-keys/medications.ts | 9 + 11 files changed, 1847 insertions(+) create mode 100644 prisma/migrations/0146_v1165_schedule_revision_source/migration.sql create mode 100644 src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts create mode 100644 src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/schedule-revisions/route.ts create mode 100644 src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx create mode 100644 src/components/medications/scheduling/schedule-history-timeline.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84cfcc443..216f00dfb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2398,6 +2398,113 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/schedule-revisions: + get: + tags: + - Medications + summary: List a medication's archived schedule eras + description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took + over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers + the Zeitplan-tab history timeline. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Era list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListScheduleRevisionsResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Medications + summary: Append a manual schedule era (pre-tracking history) + description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from + before the schedule was edited in the app. The era must end at or before the start of the live plan and must not + overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive + (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. + Audits as `medication.schedule_revision.created`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionRequest" + responses: + "201": + description: Manual era created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/medications/{id}/schedule-revisions/{revisionId}: + delete: + tags: + - Medications + summary: Delete a manually added schedule era + description: "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source: + ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: revisionId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication or revision not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -4791,6 +4898,32 @@ components: description: Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling. + CreateScheduleRevisionRequest: + type: object + properties: + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Inclusive start instant of the manual era (ISO 8601). + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan. + timesOfDay: + minItems: 1 + maxItems: 8 + type: array + items: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries. + required: + - validFrom + - validUntil + - timesOfDay + description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era." DisableCoachFlag: type: object properties: @@ -9021,6 +9154,141 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + ListScheduleRevisionsResponse: + type: object + properties: + data: + type: object + properties: + currentSince: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when + no era has been archived." + revisions: + type: array + items: + $ref: "#/components/schemas/MedicationScheduleRevision" + description: Archived eras, newest first. + required: + - currentSince + - revisions + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationScheduleRevision: + type: object + properties: + id: + type: string + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Inclusive start instant of the era. + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Exclusive end instant of the era — the moment the next plan took over. + source: + type: string + enum: + - ARCHIVED + - MANUAL + description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered + pre-tracking era (deletable). + entries: + type: array + items: + $ref: "#/components/schemas/ScheduleRevisionEntry" + required: + - id + - validFrom + - validUntil + - source + - entries + additionalProperties: false + description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, + and cadence chips mint past days against the era that was live then. + ScheduleRevisionEntry: + type: object + properties: + timesOfDay: + type: array + items: + type: string + description: Daily dose times (HH:mm, user local) the era ran at. + label: + anyOf: + - type: string + - type: "null" + dose: + anyOf: + - type: string + - type: "null" + scheduleType: + type: string + description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC). + required: + - timesOfDay + - label + - dose + - scheduleType + additionalProperties: false + description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, + …) stays server-side; this projection carries what the timeline renders. + CreateScheduleRevisionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationScheduleRevision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteScheduleRevisionResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachPrefsResponse: type: object properties: diff --git a/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql new file mode 100644 index 000000000..9d3d04533 --- /dev/null +++ b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql @@ -0,0 +1,19 @@ +-- v1.16.5 — schedule-revision provenance. +-- +-- The Zeitplan tab now lets a user append a PRE-tracking era by hand +-- ("the medication dosed at 07:00/19:00 from March to June, before I +-- edited it here"). Those rows are user-entered, not write-path +-- archives, so they must stay deletable without touching the archive +-- the schedule-replace path mints. +-- +-- `source` records the provenance: +-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable +-- history; never deletable through the API). +-- 'MANUAL' — entered through POST +-- /api/medications/{id}/schedule-revisions (deletable by +-- the owner). +-- +-- Additive + non-destructive: every existing row is a write-path +-- archive, so the default backfills correctly. +ALTER TABLE "medication_schedule_revisions" + ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31f037ec0..75f2921e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1470,6 +1470,11 @@ model MedicationScheduleRevision { /// doseWindows, label, dose, reminderGraceMinutes }` objects — enough /// to rebuild a `CanonicalSchedule` list for era minting. payload Json + /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace + /// write path (immutable history). 'MANUAL' = user-entered pre-tracking + /// era via POST /api/medications/{id}/schedule-revisions (deletable by + /// the owner). + source String @default("ARCHIVED") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts new file mode 100644 index 000000000..bd8d1d970 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -0,0 +1,74 @@ +/** + * v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * + * Removes a MANUAL schedule era (one the owner appended through the + * sibling POST). Write-path archives (`source: "ARCHIVED"`) are + * immutable history — the wholesale-replace path minted them from rows + * that actually existed, so deleting one would falsify the ledger; the + * route refuses with 409. + * + * Auth: requireAuth() + medication ownership; a revision belonging to + * another medication (or another user's medication) surfaces as 404, + * existence channel sealed like every medication sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; + +type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, revisionId } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const revision = await prisma.medicationScheduleRevision.findUnique({ + where: { id: revisionId }, + select: { id: true, medicationId: true, source: true }, + }); + if (!revision || revision.medicationId !== id) { + return apiError("Schedule revision not found", 404); + } + + if (revision.source !== "MANUAL") { + return apiError( + "Only manually added schedule eras can be deleted", + 409, + ); + } + + await prisma.medicationScheduleRevision.delete({ + where: { id: revisionId }, + }); + + await auditLog("medication.schedule_revision.deleted", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_deleted", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_id: revisionId }, + }); + + // History re-segments without the era; refresh the pre-aggregated + // compliance rollups asynchronously (best-effort). + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts new file mode 100644 index 000000000..d9774e10d --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -0,0 +1,328 @@ +/** + * v1.16.5 — schedule-era endpoints: ownership, validation, provenance. + * + * GET /api/medications/[id]/schedule-revisions + * POST /api/medications/[id]/schedule-revisions + * DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * + * The POST guards are the contract under test: a manual era must end + * at or before the start of the live plan, must not overlap an + * archived era, and is always minted with `source: "MANUAL"`. The + * DELETE refuses write-path archives (409) so the immutable history + * the replace path mints can never be falsified through the API. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findUnique: vi.fn(), + }, + medicationScheduleRevision: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + enqueueUserMedicationComplianceBackfill: vi + .fn() + .mockResolvedValue({ enqueued: true, error: null }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { DELETE } from "../[revisionId]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; + +function getReq(): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + ); +} + +function postReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +function deleteReq(revisionId: string): NextRequest { + return new NextRequest( + `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`, + { method: "DELETE" }, + ); +} + +function deleteCtx(revisionId: string) { + return { params: Promise.resolve({ id: "m1", revisionId }) }; +} + +const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z"); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Ownership guard + GET both read `medication.findUnique`; the guard + // selects `{ id, userId }`, the GET selects `{ createdAt }` — one + // superset row satisfies both. + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + createdAt: MED_CREATED_AT, + } as never); + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/medications/[id]/schedule-revisions", () => { + it("404s for another user's medication (existence sealed)", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(404); + }); + + it("returns currentSince = createdAt when no revision exists", async () => { + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe(MED_CREATED_AT.toISOString()); + expect(json.data.revisions).toEqual([]); + }); + + it("returns currentSince = newest validUntil and summarised entries", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + id: "rev-2", + validFrom: new Date("2026-03-01T00:00:00Z"), + validUntil: new Date("2026-05-01T00:00:00Z"), + source: "ARCHIVED", + payload: [ + { + timesOfDay: ["07:00", "19:00"], + label: "Morgens", + dose: null, + scheduleType: "SCHEDULED", + }, + ], + }, + { + id: "rev-1", + validFrom: new Date("2026-01-10T08:00:00Z"), + validUntil: new Date("2026-03-01T00:00:00Z"), + source: "MANUAL", + // Malformed payload must degrade, never throw on a read path. + payload: { not: "an array" }, + }, + ] as never); + + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe("2026-05-01T00:00:00.000Z"); + expect(json.data.revisions).toHaveLength(2); + expect(json.data.revisions[0].entries[0].timesOfDay).toEqual([ + "07:00", + "19:00", + ]); + expect(json.data.revisions[1].entries).toEqual([]); + expect(json.data.revisions[1].source).toBe("MANUAL"); + }); +}); + +describe("POST /api/medications/[id]/schedule-revisions", () => { + const VALID_BODY = { + validFrom: "2025-03-12T00:00:00.000Z", + validUntil: "2025-06-01T00:00:00.000Z", + timesOfDay: ["19:00", "07:00"], + }; + + it("404s for another user's medication", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s when validFrom is not before validUntil", async () => { + const res = await POST( + postReq({ ...VALID_BODY, validUntil: VALID_BODY.validFrom }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s on a malformed time literal", async () => { + const res = await POST( + postReq({ ...VALID_BODY, timesOfDay: ["25:99"] }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s when the era overlaps an archived era", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-05-01T00:00:00Z"), + validUntil: new Date("2026-01-01T00:00:00Z"), + }, + ] as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/overlap/i); + }); + + it("422s when the era ends after the live plan began", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-01-01T00:00:00Z"), + validUntil: new Date("2025-04-01T00:00:00Z"), + }, + ] as never); + // Ends 2025-06-01 — after the live plan start (2025-04-01) — and + // does NOT overlap the archived era; the boundary check must fire. + const res = await POST( + postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("creates a MANUAL revision with a daily snapshot entry", async () => { + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date(VALID_BODY.validFrom), + validUntil: new Date(VALID_BODY.validUntil), + source: "MANUAL", + payload: [], + } as never); + + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(201); + + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { + medicationId: string; + source: string; + payload: Array<{ + timesOfDay: string[]; + rrule: string | null; + windowStart: string; + windowEnd: string; + scheduleType: string; + }>; + }; + }; + expect(createArgs.data.medicationId).toBe("m1"); + expect(createArgs.data.source).toBe("MANUAL"); + expect(createArgs.data.payload).toHaveLength(1); + // Times sorted ascending; window pulled to their min/max; daily. + expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]); + expect(createArgs.data.payload[0].windowStart).toBe("07:00"); + expect(createArgs.data.payload[0].windowEnd).toBe("19:00"); + expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); + expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); + + // History re-segmented — the rollup backfill must be enqueued. + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); + +describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { + it("404s when the revision belongs to another medication", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-x", + medicationId: "other-med", + source: "MANUAL", + } as never); + const res = await DELETE(deleteReq("rev-x"), deleteCtx("rev-x")); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("409s for a write-path archive", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-a", + medicationId: "m1", + source: "ARCHIVED", + } as never); + const res = await DELETE(deleteReq("rev-a"), deleteCtx("rev-a")); + expect(res.status).toBe(409); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("deletes a MANUAL era and enqueues the rollup backfill", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-m", + medicationId: "m1", + source: "MANUAL", + } as never); + vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue( + {} as never, + ); + const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m")); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.deleted).toBe(true); + expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({ + where: { id: "rev-m" }, + }); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts new file mode 100644 index 000000000..0a4ca4259 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -0,0 +1,263 @@ +/** + * v1.16.5 — schedule-era management for the Zeitplan tab. + * + * GET /api/medications/[id]/schedule-revisions + * - lists the medication's archived schedule eras (newest first) + * plus `currentSince`, the instant the LIVE plan took over (the + * newest revision's `validUntil`, or `medication.createdAt` when + * no era has been archived yet). + * + * POST /api/medications/[id]/schedule-revisions + * - appends a MANUAL era for pre-tracking history ("dosed at + * 07:00/19:00 from March to June"). Validation: the era must end + * at or before the start of the live era and must not overlap + * any existing revision. The snapshot payload mirrors what the + * write-path archive mints (`FREQ=DAILY`, window pulled to the + * min/max of the times) so the era minter — ledger, compliance, + * cadence chips — reads it like any other revision. + * + * Auth: cookie-session or Bearer via requireAuth(); the medication is + * verified to belong to the caller before any read or write. Deleting + * a manual era lives in the `[revisionId]` sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; +import { + toRevisionPayloadEntry, + type ScheduleRevisionEntry, +} from "@/lib/medications/scheduling/schedule-eras"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * Display projection of one archived schedule row. Defensive parsing — + * a malformed payload entry degrades to an empty summary instead of + * throwing on a read path (mirrors `canonicalSchedulesFromRevision`). + */ +function summariseEntries(payload: unknown): Array<{ + timesOfDay: string[]; + label: string | null; + dose: string | null; + scheduleType: string; +}> { + if (!Array.isArray(payload)) return []; + return payload.map((raw) => { + const e = (raw ?? {}) as Partial; + return { + timesOfDay: Array.isArray(e.timesOfDay) + ? e.timesOfDay.filter((t): t is string => typeof t === "string") + : [], + label: typeof e.label === "string" ? e.label : null, + dose: typeof e.dose === "string" ? e.dose : null, + scheduleType: typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED", + }; + }); +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const revisions = await prisma.medicationScheduleRevision.findMany({ + where: { medicationId: id }, + orderBy: { validFrom: "desc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + + // The live plan has been current since the newest archived era + // ended; with no archive it has been current since creation. + const currentSince = + revisions.length > 0 + ? revisions.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + revisions[0].validUntil, + ) + : med.createdAt; + + annotate({ + action: { + name: "medication.schedule_revisions.list", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_count: revisions.length }, + }); + + return apiSuccess({ + currentSince: currentSince.toISOString(), + revisions: revisions.map((r) => ({ + id: r.id, + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + source: r.source, + entries: summariseEntries(r.payload), + })), + }); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const validFrom = new Date(parsed.data.validFrom); + const validUntil = new Date(parsed.data.validUntil); + + // Sanity floor — a fat-fingered year ("0203") would otherwise mint + // an era the timeline renders as antiquity. + if (validFrom.getUTCFullYear() < 1900) { + return apiError("validFrom must be a date after 1900", 422); + } + + const existing = await prisma.medicationScheduleRevision.findMany({ + where: { medicationId: id }, + select: { validFrom: true, validUntil: true }, + }); + + // "Before the current plan": the live era began at the newest + // archived `validUntil`; with no archive it spans up to now, so a + // manual era may cover anything up to this instant. + const liveStart = existing.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + new Date(0), + ); + const liveBoundary = existing.length > 0 ? liveStart : new Date(); + if (validUntil.getTime() > liveBoundary.getTime()) { + return apiError( + "A manual era must end before the current plan begins", + 422, + ); + } + + // No overlap with any archived interval `[validFrom, validUntil)`. + const overlaps = existing.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return apiError("The era overlaps an existing schedule era", 422); + } + + // Snapshot entry shaped exactly like the write-path archive: daily + // recurrence at the given times, window pulled to their min/max. + const times = [...parsed.data.timesOfDay].sort((a, b) => + a.localeCompare(b), + ); + const entry = toRevisionPayloadEntry({ + timesOfDay: times, + windowStart: times[0], + windowEnd: times[times.length - 1], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }); + + const revision = await prisma.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + + await auditLog("medication.schedule_revision.created", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId: revision.id }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_created", + entity_type: "medication", + entity_id: id, + }, + meta: { + revision_id: revision.id, + era_days: Math.round( + (validUntil.getTime() - validFrom.getTime()) / 86_400_000, + ), + }, + }); + + // The new era re-segments history the compliance rollups already + // pre-aggregated; refresh them asynchronously. Best-effort like + // every other rollup write-hook. + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess( + { + id: revision.id, + validFrom: revision.validFrom.toISOString(), + validUntil: revision.validUntil.toISOString(), + source: revision.source, + entries: summariseEntries(revision.payload), + }, + 201, + ); + }, +); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index d9bc5ebda..25f90fb8b 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -50,6 +50,7 @@ import { MedicationDetailSection } from "@/components/medications/medication-det import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor"; +import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline"; import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { InventorySection } from "@/components/medications/sections/inventory-section"; @@ -536,6 +537,24 @@ export function MedicationDetailTabs({

      + + {/* PLANHISTORIE — archived schedule eras (v1.16.3 effective + dating) as a quiet collapsible timeline, plus the manual + pre-tracking-era flow. Read-only for write-path archives. */} + + + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + )} + /> + {/* BESTAND — inventory readout + register / correct flows. */} diff --git a/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx b/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx new file mode 100644 index 000000000..5be67d7a7 --- /dev/null +++ b/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx @@ -0,0 +1,180 @@ +/** + * v1.16.5 — schedule-history timeline SSR smoke tests. + * + * Project convention: `renderToStaticMarkup` + assertions against the + * SSR string, react-query data seeded via `setQueryData` so `useQuery` + * resolves synchronously (no `@testing-library/react` installed). + * Interactive branches (toggle click, dialog submit, delete confirm) + * are covered by the route tests + the `defaultExpanded` render path + * here; the contracts pinned: + * + * 1. Live plan row: deduped/sorted times + "current since" line. + * 2. Collapsed default: era rows absent, toggle carries the count. + * 3. Expanded: each era renders times + date range; MANUAL eras get + * the chip + delete affordance, ARCHIVED eras get neither. + * 4. No revisions: no toggle, the add-era CTA still renders. + * 5. `eraTimes` dedupes/sorts across entries and drops non-arrays. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ScheduleHistoryTimeline, + eraTimes, + type ScheduleRevisionRow, +} from "@/components/medications/scheduling/schedule-history-timeline"; +import { queryKeys } from "@/lib/query-keys"; + +const MED_ID = "med-1"; + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); +} + +function seed( + client: QueryClient, + data: { currentSince: string; revisions: ScheduleRevisionRow[] }, +) { + client.setQueryData(queryKeys.medicationScheduleRevisions(MED_ID), data); +} + +function render( + client: QueryClient, + props?: Partial[0]>, +) { + return renderToStaticMarkup( + + + + + , + ); +} + +const MANUAL_ERA: ScheduleRevisionRow = { + id: "rev-manual", + validFrom: "2026-03-12T00:00:00.000Z", + validUntil: "2026-06-01T00:00:00.000Z", + source: "MANUAL", + entries: [ + { + timesOfDay: ["07:00", "19:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ], +}; + +const ARCHIVED_ERA: ScheduleRevisionRow = { + id: "rev-archived", + validFrom: "2026-01-10T00:00:00.000Z", + validUntil: "2026-03-12T00:00:00.000Z", + source: "ARCHIVED", + entries: [ + { + timesOfDay: ["09:00"], + label: "Morgens", + dose: null, + scheduleType: "SCHEDULED", + }, + ], +}; + +describe("ScheduleHistoryTimeline", () => { + it("renders the live plan with deduped, sorted times and the since line", () => { + const client = makeClient(); + seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] }); + const html = render(client); + expect(html).toContain("08:00 / 20:00"); + expect(html).toContain("Aktueller Plan seit"); + expect(html).toContain("01.06.2026"); + }); + + it("collapses predecessor eras by default and counts them in the toggle", () => { + const client = makeClient(); + seed(client, { + currentSince: "2026-06-01T00:00:00.000Z", + revisions: [MANUAL_ERA, ARCHIVED_ERA], + }); + const html = render(client); + expect(html).toContain("2 frühere Ären anzeigen"); + expect(html).not.toContain("07:00 / 19:00"); + expect(html).not.toContain("zeitplan-history-era"); + }); + + it("expanded: era rows carry times, range, and MANUAL-only affordances", () => { + const client = makeClient(); + seed(client, { + currentSince: "2026-06-01T00:00:00.000Z", + revisions: [MANUAL_ERA, ARCHIVED_ERA], + }); + const html = render(client, { defaultExpanded: true }); + expect(html).toContain("07:00 / 19:00"); + expect(html).toContain("12.03.2026"); + expect(html).toContain("01.06.2026"); + expect(html).toContain("Manuell ergänzt"); + expect(html).toContain("Ära löschen"); + // Exactly ONE manual chip + ONE delete button — the archived era + // renders read-only. + expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1); + expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1); + expect(html).toContain("Frühere Ären ausblenden"); + }); + + it("renders the add-era CTA without a toggle when no era exists", () => { + const client = makeClient(); + seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] }); + const html = render(client); + expect(html).toContain("Frühere Ära ergänzen"); + expect(html).not.toContain("zeitplan-history-toggle"); + }); +}); + +describe("eraTimes", () => { + it("dedupes and sorts across entries", () => { + expect( + eraTimes([ + { + timesOfDay: ["19:00", "07:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + { + timesOfDay: ["07:00", "12:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ]), + ).toEqual(["07:00", "12:00", "19:00"]); + }); + + it("tolerates a non-array timesOfDay", () => { + expect( + eraTimes([ + { + timesOfDay: undefined as unknown as string[], + label: null, + dose: null, + scheduleType: "PRN", + }, + ]), + ).toEqual([]); + }); +}); diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx new file mode 100644 index 000000000..1da2322ca --- /dev/null +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -0,0 +1,530 @@ +"use client"; + +/** + * v1.16.5 — Zeitplan-tab schedule-history timeline. + * + * The ledger and compliance tallies have minted past days against + * archived schedule eras since v1.16.3; this is the missing management + * surface. A quiet vertical timeline under the times editor: + * + * - the live plan as the accent node ("Aktueller Plan seit {Datum}", + * with its times when the parent passes them), + * - predecessor eras collapsed behind a count toggle — each row reads + * "07:00 / 19:00 · 12.03.2026 – 01.06.2026", read-only, + * - a "Frühere Ära ergänzen" flow for pre-tracking history: the + * dialog collects daily times (the existing `TimesOfDayChips` + * idiom) plus a Von/Bis date pair and POSTs a MANUAL revision, + * - MANUAL eras carry a tinted chip and a delete affordance + * (confirmed via AlertDialog); write-path archives are immutable + * and render without one. + * + * The "Bis" date is the day the NEXT plan took over (exclusive bound) — + * the dialog copy says so, and adjacency with the live plan is the + * common case. Dates convert to instants at the browser's local + * midnight, matching the user's wall clock like every schedule input. + */ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, History, Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DateInput } from "@/components/ui/date-input"; +import { Label } from "@/components/ui/label"; +import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +interface RevisionEntrySummary { + timesOfDay: string[]; + label: string | null; + dose: string | null; + scheduleType: string; +} + +export interface ScheduleRevisionRow { + id: string; + validFrom: string; + validUntil: string; + source: "ARCHIVED" | "MANUAL"; + entries: RevisionEntrySummary[]; +} + +interface ScheduleRevisionsResponse { + currentSince: string; + revisions: ScheduleRevisionRow[]; +} + +/** + * Dose-time readout of one era: the union of its entries' times, + * deduped + sorted ("07:00 / 19:00"). PRN-only eras have no times — + * the caller renders its own placeholder for an empty result. + */ +export function eraTimes(entries: RevisionEntrySummary[]): string[] { + const all = entries.flatMap((e) => + Array.isArray(e.timesOfDay) ? e.timesOfDay : [], + ); + return [...new Set(all)].sort((a, b) => a.localeCompare(b)); +} + +export function ScheduleHistoryTimeline({ + medicationId, + currentTimes, + defaultExpanded = false, +}: { + medicationId: string; + /** Live-plan dose times the parent already holds (deduped here). */ + currentTimes: string[]; + /** Start with the predecessor eras unfolded (default collapsed). */ + defaultExpanded?: boolean; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + const [expanded, setExpanded] = useState(defaultExpanded); + const [addOpen, setAddOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + + const { data, isError } = useQuery({ + queryKey: queryKeys.medicationScheduleRevisions(medicationId), + queryFn: async () => + apiGet( + `/api/medications/${medicationId}/schedule-revisions`, + ), + staleTime: 30_000, + }); + + const liveTimes = useMemo( + () => [...new Set(currentTimes)].sort((a, b) => a.localeCompare(b)), + [currentTimes], + ); + + const remove = useMutation({ + mutationFn: async (revisionId: string) => { + await apiDelete( + `/api/medications/${medicationId}/schedule-revisions/${revisionId}`, + ); + }, + onSuccess: () => { + toast.success(t("medications.detail.zeitplan.history.deletedToast")); + // An era delete re-segments history — the ledger, compliance, and + // cadence reads under this medication's prefix all go stale. + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }); + }, + onError: () => { + toast.error(t("medications.detail.zeitplan.history.deleteError")); + }, + }); + + if (isError) { + return ( +

      + {t("medications.detail.zeitplan.history.loadError")} +

      + ); + } + if (!data) return null; + + const revisions = data.revisions; + + return ( +
      +
        + {/* Live plan — the accent node. */} + 0} + dataSlot="zeitplan-history-current" + > +
        +

        + {liveTimes.length > 0 + ? liveTimes.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} +

        +

        + {t("medications.detail.zeitplan.history.currentSince", { + date: fmt.date(data.currentSince), + })} +

        +
        +
        + + {/* Predecessor eras — newest first, only when expanded. */} + {expanded && + revisions.map((revision, idx) => { + const times = eraTimes(revision.entries); + return ( + +
        +
        +

        + {times.length > 0 + ? times.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} + {revision.source === "MANUAL" && ( + + {t("medications.detail.zeitplan.history.manualChip")} + + )} +

        +

        + {fmt.date(revision.validFrom)} + {" – "} + {fmt.date(revision.validUntil)} +

        +
        + {revision.source === "MANUAL" && ( + + )} +
        +
        + ); + })} +
      + +
      + {revisions.length > 0 && ( + + )} + +
      + + setExpanded(true)} + /> + + { + if (!open) setDeleteTarget(null); + }} + > + + + + {t("medications.detail.zeitplan.history.deleteConfirmTitle")} + + + {t("medications.detail.zeitplan.history.deleteConfirmBody")} + + + + + {t("medications.detail.zeitplan.history.cancel")} + + { + if (deleteTarget) remove.mutate(deleteTarget.id); + setDeleteTarget(null); + }} + > + {t("medications.detail.zeitplan.history.deleteConfirmAction")} + + + + +
      + ); +} + +/** + * One timeline node: dot + optional rail segment down to the next row. + * The rail is a plain absolutely-positioned line (not `border-l` on the + * list) so the last row never paints a dangling tail. + */ +function TimelineRow({ + dotClassName, + railVisible, + dataSlot, + children, +}: { + dotClassName: string; + railVisible: boolean; + dataSlot: string; + children: React.ReactNode; +}) { + return ( +
    • +
    • + ); +} + +/** + * "Frühere Ära ergänzen" — collects daily dose times + the Von/Bis date + * pair and POSTs a MANUAL schedule revision. Client-side guards mirror + * the server validation (times present, Von before Bis); the server + * stays authoritative for overlap / before-current-plan and its message + * surfaces through the error toast. + */ +function AddEraDialog({ + medicationId, + open, + onOpenChange, + onCreated, +}: { + medicationId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [times, setTimes] = useState([]); + const [fromDate, setFromDate] = useState(""); + const [untilDate, setUntilDate] = useState(""); + const [validationKey, setValidationKey] = useState< + "timesMissing" | "rangeMissing" | "rangeOrder" | null + >(null); + + function reset() { + setTimes([]); + setFromDate(""); + setUntilDate(""); + setValidationKey(null); + } + + const create = useMutation({ + mutationFn: async (input: { + validFrom: string; + validUntil: string; + timesOfDay: string[]; + }) => + apiPost( + `/api/medications/${medicationId}/schedule-revisions`, + input, + ), + onSuccess: () => { + toast.success(t("medications.detail.zeitplan.history.createdToast")); + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }); + onOpenChange(false); + reset(); + onCreated(); + }, + onError: () => { + toast.error(t("medications.detail.zeitplan.history.createError")); + }, + }); + + function submit() { + if (times.length === 0) { + setValidationKey("timesMissing"); + return; + } + if (!fromDate || !untilDate) { + setValidationKey("rangeMissing"); + return; + } + // Local-midnight instants: the era bounds follow the user's wall + // clock, the same way every schedule time input does. + const from = new Date(`${fromDate}T00:00:00`); + const until = new Date(`${untilDate}T00:00:00`); + if (from.getTime() >= until.getTime()) { + setValidationKey("rangeOrder"); + return; + } + setValidationKey(null); + create.mutate({ + validFrom: from.toISOString(), + validUntil: until.toISOString(), + timesOfDay: times, + }); + } + + return ( + { + onOpenChange(next); + if (!next) reset(); + }} + > + + + + {t("medications.detail.zeitplan.history.addEraTitle")} + + + {t("medications.detail.zeitplan.history.addEraDescription")} + + +
      { + e.preventDefault(); + if (create.isPending) return; + submit(); + }} + className="space-y-4" + > +
      +

      + {t("medications.detail.zeitplan.history.timesLabel")} +

      + +
      +
      +
      + + setFromDate(e.target.value)} + data-slot="zeitplan-history-from" + /> +
      +
      + + setUntilDate(e.target.value)} + data-slot="zeitplan-history-until" + /> +
      +
      +

      + {t("medications.detail.zeitplan.history.untilHint")} +

      + {validationKey !== null && ( +

      + {validationKey === "timesMissing" && + t("medications.detail.zeitplan.history.validationTimes")} + {validationKey === "rangeMissing" && + t("medications.detail.zeitplan.history.validationRangeMissing")} + {validationKey === "rangeOrder" && + t("medications.detail.zeitplan.history.validationRangeOrder")} +

      + )} + + + + +
      +
      +
      + ); +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 7cfa3bc82..aaf7cfb7b 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -15,6 +15,7 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // ── Medications (v1.5 scheduling) ──────────────────────────────────── @@ -503,6 +504,60 @@ const medicationComplianceResponse = z "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", }); +// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). +// Archived eras come from two provenances: the wholesale-replace write +// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow +// (`MANUAL`, deletable through the `[revisionId]` DELETE). +const scheduleRevisionEntrySummary = z + .object({ + timesOfDay: z + .array(z.string()) + .describe("Daily dose times (HH:mm, user local) the era ran at."), + label: z.string().nullable(), + dose: z.string().nullable(), + scheduleType: z + .string() + .describe("Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC)."), + }) + .meta({ + id: "ScheduleRevisionEntry", + description: + "Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders.", + }); + +const scheduleRevisionResource = z + .object({ + id: z.string(), + validFrom: z.iso + .datetime() + .describe("Inclusive start instant of the era."), + validUntil: z.iso + .datetime() + .describe("Exclusive end instant of the era — the moment the next plan took over."), + source: z + .enum(["ARCHIVED", "MANUAL"]) + .describe( + "Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered pre-tracking era (deletable).", + ), + entries: z.array(scheduleRevisionEntrySummary), + }) + .meta({ + id: "MedicationScheduleRevision", + description: + "One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then.", + }); + +const scheduleRevisionListResponse = z.object({ + currentSince: z.iso + .datetime() + .describe( + "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", + ), + revisions: z + .array(scheduleRevisionResource) + .describe("Archived eras, newest first."), +}); + // v1.5.0 — natural-language medication extraction route. The wizard's // optional "Beschreiben" overlay POSTs a free-text description and // receives a partial structured payload the form merges onto whatever @@ -827,4 +882,101 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/schedule-revisions": { + get: { + tags: ["Medications"], + summary: "List a medication's archived schedule eras", + description: + "Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Era list.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionListResponse, + "ListScheduleRevisionsResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Append a manual schedule era (pre-tracking history)", + description: + "Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: scheduleRevisionCreateSchema }, + }, + }, + responses: { + "201": { + description: "Manual era created.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionResource, + "CreateScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/schedule-revisions/{revisionId}": { + delete: { + tags: ["Medications"], + summary: "Delete a manually added schedule era", + description: + "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.", + requestParams: { + path: z.object({ id: z.string(), revisionId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: + "Medication or revision not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 02384524c..278ef062e 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -27,6 +27,15 @@ export const medicationKeys = { */ medicationInventory: (medicationId: string) => ["medications", medicationId, "inventory"] as const, + /** + * v1.16.5 — archived schedule eras + `currentSince` + * (`GET /api/medications/[id]/schedule-revisions`), consumed by the + * Zeitplan tab's history timeline. Rides under the `["medications"]` + * prefix so a schedule replace (which may archive a new era) + * invalidates it together with the detail read. + */ + medicationScheduleRevisions: (medicationId: string) => + ["medications", medicationId, "schedule-revisions"] as const, /** * v1.15.18 — per-medication dose-history ledger * (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the From 44f344bf52966c2f4e04189c44f26ad3a5c3a0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 12:08:41 +0200 Subject: [PATCH 4/9] fix(medications): a manual era cannot swallow tracked live history The manual-era boundary anchors on the medication's creation instead of the current moment, so a backfilled era can only describe time before the app was tracking. --- .../__tests__/route.test.ts | 19 +++++++++++++++++++ .../[id]/schedule-revisions/route.ts | 19 ++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts index d9774e10d..d2c02ed12 100644 --- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -233,6 +233,25 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { expect(json.error).toMatch(/current plan/i); }); + it("422s when no revision exists and the era reaches past createdAt", async () => { + // With zero archived revisions the live plan has covered everything + // since `createdAt` (2026-01-10); a manual era ending inside the + // tracked window would re-score live compliance against the manual + // snapshot. The boundary is createdAt, not "now". + const res = await POST( + postReq({ + ...VALID_BODY, + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2026-02-01T00:00:00.000Z", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + it("creates a MANUAL revision with a daily snapshot entry", async () => { vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ id: "rev-new", diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 0a4ca4259..c7cd2075e 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -137,6 +137,14 @@ export const POST = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, }); @@ -162,13 +170,18 @@ export const POST = apiHandler( }); // "Before the current plan": the live era began at the newest - // archived `validUntil`; with no archive it spans up to now, so a - // manual era may cover anything up to this instant. + // archived `validUntil`; with no archive the live plan has covered + // everything since the medication was created (the era splitter + // reads `[newest validUntil, ∞)` — or the whole range when no + // revision exists), so a manual era must end at or before + // `createdAt`. Allowing it to reach "now" would let the manual + // snapshot swallow tracked live history and re-score compliance + // against the wrong plan. const liveStart = existing.reduce( (latest, r) => (r.validUntil > latest ? r.validUntil : latest), new Date(0), ); - const liveBoundary = existing.length > 0 ? liveStart : new Date(); + const liveBoundary = existing.length > 0 ? liveStart : med.createdAt; if (validUntil.getTime() > liveBoundary.getTime()) { return apiError( "A manual era must end before the current plan begins", From a4b3d2fcd0308b95dd49f4fae079e3c5be64a402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 12:08:46 +0200 Subject: [PATCH 5/9] =?UTF-8?q?chore(release):=20v1.16.5=20=E2=80=94=20a?= =?UTF-8?q?=20guided=20coach=20dialog=20and=20a=20schedule=20that=20shows?= =?UTF-8?q?=20its=20past?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724e42f02..f56efc092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past + +### Added + +- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over. +- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history. +- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch. +- **Invites mint up to fifty uses** with a scrolling redemption list. + +### Changed + +- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call. + +### Fixed + +- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact. + ## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 216f00dfb..5f6862665 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.4 + version: 1.16.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 0750df209..fd1b1c58b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.4", + "version": "1.16.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 40f7196c20f804f844a1595c516c91fa5bd8ee30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 13:56:59 +0200 Subject: [PATCH 6/9] feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone. --- docs/api/openapi.yaml | 75 ++++- e2e/coach-guided-questions.spec.ts | 111 ++++++- messages/de.json | 34 ++- messages/en.json | 34 ++- messages/es.json | 34 ++- messages/fr.json | 34 ++- messages/it.json | 34 ++- messages/pl.json | 34 ++- .../migration.sql | 14 + prisma/schema.prisma | 8 + src/app/admin/[section]/renderer.tsx | 2 + .../admin/ai-settings/__tests__/route.test.ts | 217 ++++++++++++++ src/app/api/insights/chat/route.ts | 30 +- src/app/api/medications/[id]/route.ts | 8 +- .../schedule-revisions/[revisionId]/route.ts | 272 +++++++++++++++++- .../__tests__/route.test.ts | 272 +++++++++++++++++- .../[id]/schedule-revisions/route.ts | 128 +++++---- src/app/api/medications/route.ts | 4 +- src/app/auth/login/page.tsx | 18 +- src/app/insights/page.tsx | 8 +- src/app/page.tsx | 8 +- .../admin/__tests__/admin-shell.test.tsx | 10 +- .../__tests__/login-overview-csv.test.ts | 8 +- .../user-management-responsive.test.tsx | 9 +- src/components/admin/_shared.tsx | 13 + src/components/admin/admin-shell.tsx | 6 +- src/components/admin/ai-quality-section.tsx | 49 ++-- .../admin/ai-server-key-section.tsx | 188 ++++++++++++ .../admin/api-token-overview-section.tsx | 10 +- .../admin/app-log-preview-section.tsx | 49 ++-- src/components/admin/assistant-section.tsx | 144 +++++----- src/components/admin/backups-section.tsx | 51 ++-- src/components/admin/bug-report-section.tsx | 37 +-- .../admin/coach-feedback-section.tsx | 37 ++- src/components/admin/danger-zone-section.tsx | 2 +- .../admin/feedback-inbox-section.tsx | 10 +- .../admin/general-settings-section.tsx | 14 +- src/components/admin/glitchtip-section.tsx | 29 +- .../admin/invite-tokens-section.tsx | 151 +++++----- .../admin/login-overview-section.tsx | 16 +- src/components/admin/recent-audit-preview.tsx | 37 ++- src/components/admin/reminders-section.tsx | 25 +- src/components/admin/services-section.tsx | 17 +- .../admin/system-status-section.tsx | 14 +- .../admin/system-status-summary.tsx | 19 +- src/components/admin/umami-section.tsx | 29 +- .../admin/user-management-section.tsx | 56 ++-- src/components/admin/version-tile-section.tsx | 74 ++--- .../admin/web-push-vapid-section.tsx | 37 +-- .../guided-questions-machine.test.ts | 52 ++++ .../coach-panel/coach-conversation.tsx | 17 +- .../coach-panel/guided-questions-machine.ts | 47 ++- .../insights/coach-panel/use-coach.ts | 22 +- .../medications/__tests__/card-parts.test.tsx | 71 +++++ .../card-parts/medication-compliance-bars.tsx | 26 +- .../medications/glp1-medication-card.tsx | 10 + .../medications/medication-card.tsx | 9 + .../schedule-history-timeline.test.tsx | 8 +- .../scheduling/schedule-history-timeline.tsx | 157 +++++++--- src/components/providers.tsx | 30 +- .../about-section-update-badge.test.tsx | 8 +- .../account-section-timezone-seed.test.ts | 6 +- .../advanced-account-delete.test.tsx | 4 +- .../__tests__/advanced-research-mode.test.tsx | 4 +- .../dashboard-layout-section.test.tsx | 24 +- .../settings/__tests__/import-panel.test.tsx | 6 +- .../insights-pill-order-visibility.test.tsx | 11 +- .../__tests__/time-format-select.test.tsx | 12 +- src/components/settings/about-me-section.tsx | 2 +- src/components/settings/about-section.tsx | 11 +- src/components/settings/account-section.tsx | 15 +- src/components/settings/advanced-section.tsx | 26 +- src/components/settings/ai-section.tsx | 28 +- .../settings/ai/ai-insights-card.tsx | 2 +- .../settings/ai/disable-coach-card.tsx | 10 +- src/components/settings/api-section.tsx | 9 +- .../settings/coach-memory-section.tsx | 120 ++++---- src/components/settings/coach-nudge-card.tsx | 2 +- .../settings/coach-prefs-section.tsx | 172 +++-------- .../settings/cycle-tracking-card.tsx | 2 +- .../settings/dashboard-layout-section.tsx | 2 +- src/components/settings/dashboard-section.tsx | 5 +- src/components/settings/export-section.tsx | 12 +- src/components/settings/import-panel.tsx | 12 +- .../settings/injection-sites-card.tsx | 4 +- .../settings/insights-pill-order-section.tsx | 12 +- src/components/settings/insights-section.tsx | 5 +- .../settings/integration-status-pill.tsx | 9 +- .../settings/integrations-section.tsx | 5 +- .../settings/integrations/fitbit-card.tsx | 11 +- .../settings/integrations/whoop-card.tsx | 11 +- .../settings/integrations/withings-card.tsx | 2 +- .../settings/mood-reminder-card.tsx | 11 +- .../settings/notification-status-card.tsx | 42 ++- .../settings/notifications-section.tsx | 5 +- src/components/settings/ntfy-card.tsx | 2 +- .../settings/section-placeholder.tsx | 5 +- src/components/settings/sources-section.tsx | 46 ++- src/components/settings/telegram-card.tsx | 2 +- .../settings/thresholds-editor-section.tsx | 2 +- .../settings/thresholds-section.tsx | 5 +- .../settings/time-format-select.tsx | 7 +- src/components/settings/web-push-card.tsx | 2 +- .../ai/coach/__tests__/self-context.test.ts | 23 ++ src/lib/ai/coach/self-context-questions.ts | 23 +- src/lib/ai/coach/types.ts | 9 + .../__tests__/window-status.test.ts | 101 +++++++ .../__tests__/schedule-eras.test.ts | 50 ++++ .../medications/scheduling/schedule-eras.ts | 17 +- src/lib/medications/scheduling/slot-upsert.ts | 8 +- src/lib/medications/window-status.ts | 53 ++++ src/lib/openapi/routes/medications.ts | 46 ++- src/lib/queries/use-dashboard-snapshot.ts | 66 +++-- src/lib/query-keys/admin.ts | 6 + src/lib/validations/schedule-revision.ts | 34 ++- 115 files changed, 3014 insertions(+), 1084 deletions(-) create mode 100644 prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql create mode 100644 src/app/api/admin/ai-settings/__tests__/route.test.ts create mode 100644 src/components/admin/ai-server-key-section.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5f6862665..91ff2f66a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2466,12 +2466,61 @@ paths: "422": *a3 "429": *a4 /api/medications/{id}/schedule-revisions/{revisionId}: + patch: + tags: + - Medications + summary: Correct a recorded schedule era + description: "Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the + immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in + every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the + era must end at or before the start of the live plan and must not overlap another active era; violations return + 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: revisionId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScheduleRevisionRequest" + responses: + "200": + description: Era corrected. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication or revision not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The revision has already been superseded by a correction. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 delete: tags: - Medications summary: Delete a manually added schedule era - description: "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source: - ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." + description: "Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting + a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are + immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." parameters: - in: path name: id @@ -4912,18 +4961,21 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan. timesOfDay: + description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries; duplicates are collapsed. minItems: 1 maxItems: 8 type: array items: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ - description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries. required: - validFrom - validUntil - timesOfDay description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era." + UpdateScheduleRevisionRequest: + description: "Corrected schedule-era payload: replacement era bounds plus the daily dose times that were live during the era." + $ref: "#/components/schemas/CreateScheduleRevisionRequest" DisableCoachFlag: type: object properties: @@ -9266,6 +9318,23 @@ components: - data - error additionalProperties: false + UpdateScheduleRevisionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationScheduleRevision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DeleteScheduleRevisionResponse: type: object properties: diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 77e388876..3d708b2ca 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -22,9 +22,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * 5. The entry-card "later" hides the offer without touching the * thread. * - * The questions API is mocked — the flow itself is purely client-side - * until an answer is sent, and no answer is sent here (the chat SSE - * stream stays out of scope). + * 6. v1.16.6 — answering a question streams a contextual Coach + * reaction (mocked SSE) BEFORE the next question appears; the + * sequence advances only once the adopt offer settles. + * + * The questions API is mocked; the answer test also mocks the chat SSE + * POST (the reaction content itself is the model's business, only the + * ordering contract is pinned here). */ test.describe("Coach guided clarifying questions", () => { test.use({ storageState: STORAGE_STATE_PATH }); @@ -34,6 +38,7 @@ test.describe("Coach guided clarifying questions", () => { "Guided e2e question two?", "Guided e2e question three?", ]; + const REACTION = "Thanks for sharing — that helps me read your numbers."; test.beforeEach(async ({ page }) => { // Empty conversation list so the page surface paints the empty @@ -41,14 +46,68 @@ test.describe("Coach guided clarifying questions", () => { // Wide pattern on purpose: the list GET may fire with or without a // cursor query. Neither the detail GET nor the SSE POST fires in // this spec (no conversation is opened, no message is sent). - await page.route("**/api/insights/chat*", (route) => - route.fulfill({ + await page.route("**/api/insights/chat*", (route) => { + // v1.16.6 — the answer test POSTs the guided answer through the + // normal chat pipeline; serve a minimal SSE reaction. GETs keep + // the empty conversation list. + if (route.request().method() === "POST") { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: + `data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` + + `data: ${JSON.stringify({ + type: "done", + conversationId: "conv-guided-e2e", + messageId: "msg-guided-e2e", + })}\n\n`, + }); + } + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { conversations: [], nextCursor: null }, error: null, }), + }); + }); + // Detail refetch after the SSE `done` frame. + await page.route("**/api/insights/chat/conv-guided-e2e", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + id: "conv-guided-e2e", + title: "Guided e2e", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 2, + summary: null, + messages: [ + { + id: "msg-user-e2e", + role: "user", + content: "Yes, hypertension since 2019.", + createdAt: new Date().toISOString(), + metricSource: null, + providerType: null, + promptVersion: null, + }, + { + id: "msg-guided-e2e", + role: "assistant", + content: REACTION, + createdAt: new Date().toISOString(), + metricSource: null, + providerType: "mock", + promptVersion: null, + }, + ], + }, + error: null, + }), }), ); await page.route("**/api/insights/coach/nudge-status*", (route) => @@ -148,4 +207,46 @@ test.describe("Coach guided clarifying questions", () => { page.locator('[data-slot="coach-guided-question"]'), ).toHaveCount(0); }); + + test("an answer streams a Coach reaction before the next question", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await offer.locator('[data-slot="coach-guided-offer-start"]').click(); + + const current = page.locator( + '[data-slot="coach-guided-question"][data-state="current"]', + ); + await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 }); + + // Answer through the composer — the message rides the normal chat + // pipeline (mocked SSE above). + const textarea = page.locator('[data-slot="coach-input-textarea"]'); + await textarea.fill("Yes, hypertension since 2019."); + await page.locator('[data-slot="coach-input-send"]').click(); + + // Order contract: the streamed Coach reaction appears, the adopt + // offer follows, and NO next question is current yet. + await expect(page.getByText(REACTION).first()).toBeVisible({ + timeout: 10_000, + }); + const adopt = page.locator('[data-slot="coach-self-context-adopt"]'); + await expect(adopt).toBeVisible({ timeout: 10_000 }); + await expect(current).toHaveCount(0); + + // Settling the offer (decline via ✕) releases the next question. + await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click(); + await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 }); + await expect( + current.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("2"); + }); }); diff --git a/messages/de.json b/messages/de.json index f59833f71..36652cfe3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1610,6 +1610,12 @@ "addEra": "Frühere Ära ergänzen", "addEraTitle": "Frühere Ära ergänzen", "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde — etwa bevor du es hier angepasst hast.", + "editAria": "Ära bearbeiten", + "editEraTitle": "Ära bearbeiten", + "editEraDescription": "Passe die Einnahmezeiten und den Zeitraum dieser Ära an.", + "archivedEditHint": "Diese Ära wurde automatisch aufgezeichnet — deine Änderung korrigiert die aufgezeichnete Historie; das Original bleibt intern als Beleg erhalten.", + "updatedToast": "Ära aktualisiert", + "updateError": "Die Ära konnte nicht aktualisiert werden. Sie darf sich nicht mit anderen Ären überschneiden und muss vor dem aktuellen Plan enden.", "timesLabel": "Einnahmezeiten", "fromLabel": "Von", "untilLabel": "Bis", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Kurz", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Ausführlich", + "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").", + "settingsSourcesPointerLink": "Coach öffnen", "settingsExcludeLabel": "Metriken ausschließen", "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", "settingsContextLabel": "Kontext mitschicken", @@ -3507,7 +3515,7 @@ "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", "integrationsHintLink": "Integrationen öffnen", "cardTitle": "Quellen-Priorität pro Metrik", - "help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.", + "help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch. Die übrigen bleiben für die Audit-Spur gespeichert.", "resetDefaults": "Auf Standard zurücksetzen", "moveUp": "Nach oben", "moveDown": "Nach unten", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "Admin-OpenAI", - "body": "Vom Betreiber bereitgestellter OpenAI-Key. Wird als letzter Fallback genutzt, wenn kein persönlicher Provider konfiguriert ist. Hier ist nichts einzustellen — die Anzeige bedeutet, dass der Betreiber ihn eingerichtet hat.", + "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Provider. Hier ist nichts einzustellen.", "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert." }, "testProvider": "Aktiven Provider testen", @@ -4277,6 +4285,7 @@ "subtitle": "Systemstatus, Benutzer und Einstellungen", "settingsSaveError": "Einstellungen konnten nicht gespeichert werden", "systemStatus": "Systemstatus", + "systemStatusDescription": "Laufzeitdaten der Instanz: Datenbank, Worker und Bestandszähler.", "database": "Datenbank", "databaseConnected": "Verbunden", "databaseError": "Fehler", @@ -4298,6 +4307,23 @@ "empty": "Erfassung läuft — Daten erscheinen in 1–2 Minuten.", "loadError": "Host-Metriken konnten nicht geladen werden. Bitte die Seite neu laden." }, + "aiServerKey": { + "title": "KI-Server-Schlüssel", + "description": "Ein betreiberweiter Provider-Schlüssel, auf den jedes Konto zurückfallen kann, wenn kein eigener Schlüssel hinterlegt ist.", + "byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.", + "consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.", + "statusConfigured": "Eingerichtet", + "statusNotConfigured": "Nicht eingerichtet", + "keyLabel": "API-Schlüssel", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Hinterlegt ({preview}) — neuen Schlüssel eingeben, um ihn zu ersetzen", + "modelLabel": "Modell", + "baseUrlLabel": "Basis-URL", + "save": "Speichern", + "remove": "Schlüssel entfernen", + "saved": "KI-Server-Schlüssel gespeichert", + "error": "Speichern des KI-Server-Schlüssels fehlgeschlagen" + }, "aiQuality": { "title": "Auswertungs-Qualität", "loading": "Feedback-Zusammenfassung wird geladen...", @@ -4333,6 +4359,7 @@ "workerStopped": "Gestoppt", "lastReminderCheck": "Letzter Reminder-Check", "appSettings": "Anwendungseinstellungen", + "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", "defaultLanguage": "Standard-Sprache", @@ -4463,6 +4490,7 @@ "revokeError": "Einladung konnte nicht widerrufen werden." }, "userManagement": "Benutzerverwaltung", + "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", "userName": "Name", "userEmail": "E-Mail", "userRole": "Rolle", @@ -4484,6 +4512,7 @@ "reset": "Zurücksetzen", "loadingUsers": "Lade Benutzer...", "apiTokens": "API-Tokens", + "apiTokensDescription": "Alle aktiven und widerrufenen API-Tokens über alle Konten hinweg.", "tokenUser": "Benutzer", "tokenName": "Name", "tokenPermissions": "Berechtigungen", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "Noch keine API-Tokens", "tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.", "loginOverview": "Anmeldeübersicht", + "loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.", "allAuthEvents": "Alle Auth-Ereignisse", "failedOnly": "Nur fehlgeschlagene", "loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.", diff --git a/messages/en.json b/messages/en.json index a31554d4b..83da91264 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1610,6 +1610,12 @@ "addEra": "Add earlier era", "addEraTitle": "Add earlier era", "addEraDescription": "Record the times this medication used to be taken at — for example before you adjusted it here.", + "editAria": "Edit era", + "editEraTitle": "Edit era", + "editEraDescription": "Adjust the dose times and the date range of this era.", + "archivedEditHint": "This era was recorded automatically — your change corrects the recorded history; the original is kept internally as a record.", + "updatedToast": "Era updated", + "updateError": "Could not update the era. It must not overlap other eras and must end before the current plan.", "timesLabel": "Dose times", "fromLabel": "From", "untilLabel": "Until", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Brief", "settingsVerbosityDefault": "Default", "settingsVerbosityDetailed": "Detailed", + "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).", + "settingsSourcesPointerLink": "Open the coach", "settingsExcludeLabel": "Exclude metrics", "settingsExcludeHint": "The Coach will not see data for selected metrics.", "settingsContextLabel": "Include context", @@ -3507,7 +3515,7 @@ "integrationsHint": "Connect or disconnect data sources on the Integrations page.", "integrationsHintLink": "Open integrations", "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", + "help": "When several sources deliver the same value on the same day, the top source counts as canonical. The others stay stored for the audit trail.", "resetDefaults": "Reset to defaults", "moveUp": "Move up", "moveDown": "Move down", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", + "body": "Operator-provided OpenAI key, used as the last fallback when no personal provider is configured. Nothing to configure here.", "notConfigured": "The operator has not configured a shared OpenAI key on this instance." }, "testProvider": "Test active provider", @@ -4277,6 +4285,7 @@ "subtitle": "System status, users, and settings", "settingsSaveError": "Failed to save settings", "systemStatus": "System Status", + "systemStatusDescription": "Runtime state of this instance: database, worker, and record counts.", "database": "Database", "databaseConnected": "Connected", "databaseError": "Error", @@ -4298,6 +4307,23 @@ "empty": "Sampling started — data appears in 1–2 minutes.", "loadError": "Failed to load host metrics. Try refreshing the page." }, + "aiServerKey": { + "title": "AI server key", + "description": "An operator-wide provider key every account can fall back to when no personal key is configured.", + "byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.", + "consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.", + "statusConfigured": "Configured", + "statusNotConfigured": "Not configured", + "keyLabel": "API key", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Stored ({preview}) — enter a new key to replace it", + "modelLabel": "Model", + "baseUrlLabel": "Base URL", + "save": "Save", + "remove": "Remove key", + "saved": "AI server key settings saved", + "error": "Saving the AI server key failed" + }, "aiQuality": { "title": "Insights Quality", "loading": "Loading feedback summary...", @@ -4333,6 +4359,7 @@ "workerStopped": "Stopped", "lastReminderCheck": "Last Reminder Check", "appSettings": "Application Settings", + "appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.", "registrationEnabled": "Allow registration", "registrationEnabledDescription": "When disabled, new users cannot register.", "defaultLanguage": "Default language", @@ -4463,6 +4490,7 @@ "revokeError": "Could not revoke the invite." }, "userManagement": "User Management", + "userManagementDescription": "Manage accounts: roles, password resets, and forced logouts.", "userName": "Name", "userEmail": "Email", "userRole": "Role", @@ -4484,6 +4512,7 @@ "reset": "Reset", "loadingUsers": "Loading users...", "apiTokens": "API Tokens", + "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", "tokenName": "Name", "tokenPermissions": "Permissions", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "No API tokens yet", "tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.", "loginOverview": "Login Overview", + "loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.", "allAuthEvents": "All auth events", "failedOnly": "Failed only", "loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.", diff --git a/messages/es.json b/messages/es.json index 64d69e29f..8f816c012 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1610,6 +1610,12 @@ "addEra": "Añadir etapa anterior", "addEraTitle": "Añadir etapa anterior", "addEraDescription": "Registra a qué horas se tomaba antes este medicamento, por ejemplo antes de ajustarlo aquí.", + "editAria": "Editar etapa", + "editEraTitle": "Editar etapa", + "editEraDescription": "Ajusta las horas de toma y el periodo de esta etapa.", + "archivedEditHint": "Esta etapa se registró automáticamente: tu cambio corrige el historial registrado; el original se conserva internamente como constancia.", + "updatedToast": "Etapa actualizada", + "updateError": "No se pudo actualizar la etapa. No debe solaparse con otras etapas y debe terminar antes del plan actual.", "timesLabel": "Horas de toma", "fromLabel": "Desde", "untilLabel": "Hasta", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Estándar", "settingsVerbosityDetailed": "Detallado", + "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).", + "settingsSourcesPointerLink": "Abrir el coach", "settingsExcludeLabel": "Excluir métricas", "settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.", "settingsEvidenceLabel": "Mostrar evidencia por defecto", @@ -3507,7 +3515,7 @@ "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", "integrationsHintLink": "Abrir integraciones", "cardTitle": "Prioridad de fuentes por métrica", - "help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.", + "help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica. Las demás se conservan para la traza de auditoría.", "resetDefaults": "Restablecer valores predeterminados", "moveUp": "Subir", "moveDown": "Bajar", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI de administrador", - "body": "Clave de OpenAI proporcionada por el operador. Se usa como último recurso cuando no hay ningún proveedor personal configurado. No hay nada que configurar en esta fila — su visibilidad significa que el operador la ha habilitado.", + "body": "Clave de OpenAI proporcionada por el operador, usada como último recurso sin proveedor personal. No hay nada que configurar aquí.", "notConfigured": "El operador no ha configurado una clave de OpenAI compartida en esta instancia." }, "testProvider": "Probar el proveedor activo", @@ -4277,6 +4285,7 @@ "subtitle": "Estado del sistema, usuarios y ajustes", "settingsSaveError": "No se pudieron guardar los ajustes", "systemStatus": "Estado del sistema", + "systemStatusDescription": "Estado de ejecución de esta instancia: base de datos, worker y contadores.", "database": "Base de datos", "databaseConnected": "Conectada", "databaseError": "Error", @@ -4298,6 +4307,23 @@ "empty": "Muestreo iniciado: los datos aparecen en 1–2 minutos.", "loadError": "No se pudieron cargar las métricas del host. Prueba a recargar la página." }, + "aiServerKey": { + "title": "Clave de IA del servidor", + "description": "Una clave de proveedor a nivel de operador a la que puede recurrir cualquier cuenta cuando no hay una clave personal configurada.", + "byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.", + "consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.", + "statusConfigured": "Configurada", + "statusNotConfigured": "Sin configurar", + "keyLabel": "Clave de API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Guardada ({preview}) — introduce una clave nueva para reemplazarla", + "modelLabel": "Modelo", + "baseUrlLabel": "URL base", + "save": "Guardar", + "remove": "Eliminar clave", + "saved": "Ajustes de la clave de IA del servidor guardados", + "error": "No se pudo guardar la clave de IA del servidor" + }, "aiQuality": { "title": "Calidad de los análisis", "loading": "Cargando resumen de comentarios...", @@ -4333,6 +4359,7 @@ "workerStopped": "Detenido", "lastReminderCheck": "Última comprobación de recordatorios", "appSettings": "Ajustes de la aplicación", + "appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.", "registrationEnabled": "Permitir el registro", "registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.", "defaultLanguage": "Idioma predeterminado", @@ -4463,6 +4490,7 @@ "revokeError": "No se pudo revocar la invitación." }, "userManagement": "Gestión de usuarios", + "userManagementDescription": "Gestiona las cuentas: roles, restablecimientos de contraseña y cierres de sesión forzados.", "userName": "Nombre", "userEmail": "Correo electrónico", "userRole": "Rol", @@ -4484,6 +4512,7 @@ "reset": "Restablecer", "loadingUsers": "Cargando usuarios...", "apiTokens": "Tokens de API", + "apiTokensDescription": "Todos los tokens de API activos y revocados de todas las cuentas.", "tokenUser": "Usuario", "tokenName": "Nombre", "tokenPermissions": "Permisos", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "Aún no hay tokens de API", "tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.", "loginOverview": "Resumen de inicios de sesión", + "loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.", "allAuthEvents": "Todos los eventos de autenticación", "failedOnly": "Solo fallidos", "loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.", diff --git a/messages/fr.json b/messages/fr.json index 3a65daf4c..69e0638d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1610,6 +1610,12 @@ "addEra": "Ajouter une période antérieure", "addEraTitle": "Ajouter une période antérieure", "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant — par exemple avant de l'ajuster ici.", + "editAria": "Modifier la période", + "editEraTitle": "Modifier la période", + "editEraDescription": "Ajustez les heures de prise et la plage de dates de cette période.", + "archivedEditHint": "Cette période a été enregistrée automatiquement — votre modification corrige l'historique enregistré ; l'original est conservé en interne comme trace.", + "updatedToast": "Période mise à jour", + "updateError": "Impossible de mettre à jour la période. Elle ne doit pas chevaucher d'autres périodes et doit se terminer avant le plan actuel.", "timesLabel": "Heures de prise", "fromLabel": "Du", "untilLabel": "Jusqu'au", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Bref", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Détaillé", + "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).", + "settingsSourcesPointerLink": "Ouvrir le coach", "settingsExcludeLabel": "Exclure des métriques", "settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.", "settingsEvidenceLabel": "Afficher les preuves par défaut", @@ -3507,7 +3515,7 @@ "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", "integrationsHintLink": "Ouvrir les intégrations", "cardTitle": "Priorité des sources par métrique", - "help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.", + "help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique. Les autres restent enregistrées pour la piste d’audit.", "resetDefaults": "Rétablir les valeurs par défaut", "moveUp": "Monter", "moveDown": "Descendre", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI administrateur", - "body": "Clé OpenAI fournie par l’opérateur. Utilisée en dernier recours lorsqu’aucun fournisseur personnel n’est configuré. Rien à configurer sur cette ligne — sa visibilité signifie que l’opérateur l’a mise en place.", + "body": "Clé OpenAI fournie par l’opérateur, utilisée en dernier recours sans fournisseur personnel. Rien à configurer ici.", "notConfigured": "L’opérateur n’a pas configuré de clé OpenAI partagée sur cette instance." }, "testProvider": "Tester le fournisseur actif", @@ -4277,6 +4285,7 @@ "subtitle": "État du système, utilisateurs et paramètres", "settingsSaveError": "Échec de l'enregistrement des paramètres", "systemStatus": "État du système", + "systemStatusDescription": "État d’exécution de cette instance : base de données, worker et compteurs.", "database": "Base de données", "databaseConnected": "Connectée", "databaseError": "Erreur", @@ -4298,6 +4307,23 @@ "empty": "Échantillonnage démarré — les données apparaissent dans 1 à 2 minutes.", "loadError": "Échec du chargement des métriques de l'hôte. Essayez d'actualiser la page." }, + "aiServerKey": { + "title": "Clé IA du serveur", + "description": "Une clé de fournisseur à l'échelle de l'opérateur sur laquelle chaque compte peut se rabattre lorsqu'aucune clé personnelle n'est configurée.", + "byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.", + "consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.", + "statusConfigured": "Configurée", + "statusNotConfigured": "Non configurée", + "keyLabel": "Clé API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Enregistrée ({preview}) — saisissez une nouvelle clé pour la remplacer", + "modelLabel": "Modèle", + "baseUrlLabel": "URL de base", + "save": "Enregistrer", + "remove": "Supprimer la clé", + "saved": "Paramètres de la clé IA du serveur enregistrés", + "error": "Échec de l'enregistrement de la clé IA du serveur" + }, "aiQuality": { "title": "Qualité des analyses", "loading": "Chargement du résumé des retours...", @@ -4333,6 +4359,7 @@ "workerStopped": "Arrêté", "lastReminderCheck": "Dernière vérification des rappels", "appSettings": "Paramètres de l'application", + "appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.", "registrationEnabled": "Autoriser l'inscription", "registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.", "defaultLanguage": "Langue par défaut", @@ -4463,6 +4490,7 @@ "revokeError": "Impossible de révoquer l'invitation." }, "userManagement": "Gestion des utilisateurs", + "userManagementDescription": "Gérez les comptes : rôles, réinitialisations de mot de passe et déconnexions forcées.", "userName": "Nom", "userEmail": "E-mail", "userRole": "Rôle", @@ -4484,6 +4512,7 @@ "reset": "Réinitialiser", "loadingUsers": "Chargement des utilisateurs...", "apiTokens": "Jetons d'API", + "apiTokensDescription": "Tous les jetons d’API actifs et révoqués de l’ensemble des comptes.", "tokenUser": "Utilisateur", "tokenName": "Nom", "tokenPermissions": "Autorisations", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "Aucun jeton d'API pour l'instant", "tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.", "loginOverview": "Aperçu des connexions", + "loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.", "allAuthEvents": "Tous les événements d'authentification", "failedOnly": "Échecs uniquement", "loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.", diff --git a/messages/it.json b/messages/it.json index b69b182de..c43b80afa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1610,6 +1610,12 @@ "addEra": "Aggiungi periodo precedente", "addEraTitle": "Aggiungi periodo precedente", "addEraDescription": "Registra a che ora veniva assunto questo farmaco in passato, ad esempio prima di modificarlo qui.", + "editAria": "Modifica periodo", + "editEraTitle": "Modifica periodo", + "editEraDescription": "Modifica gli orari di assunzione e l'intervallo di date di questo periodo.", + "archivedEditHint": "Questo periodo è stato registrato automaticamente: la modifica corregge la cronologia registrata; l'originale viene conservato internamente come traccia.", + "updatedToast": "Periodo aggiornato", + "updateError": "Impossibile aggiornare il periodo. Non deve sovrapporsi ad altri periodi e deve terminare prima del piano attuale.", "timesLabel": "Orari di assunzione", "fromLabel": "Dal", "untilLabel": "Fino al", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Dettagliata", + "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).", + "settingsSourcesPointerLink": "Apri il coach", "settingsExcludeLabel": "Escludi metriche", "settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.", "settingsEvidenceLabel": "Mostra le evidenze per default", @@ -3507,7 +3515,7 @@ "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", "integrationsHintLink": "Apri le integrazioni", "cardTitle": "Priorità delle fonti per metrica", - "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", + "help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica. Le altre restano memorizzate per la traccia di controllo.", "resetDefaults": "Ripristina i valori predefiniti", "moveUp": "Sposta su", "moveDown": "Sposta giù", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI amministratore", - "body": "Chiave OpenAI fornita dall’operatore. Usata come ultima risorsa quando nessun provider personale è configurato. Non c’è nulla da configurare in questa riga — la sua visibilità indica che l’operatore l’ha attivata.", + "body": "Chiave OpenAI fornita dall’operatore, usata come ultima risorsa senza provider personale. Non c’è nulla da configurare qui.", "notConfigured": "L’operatore non ha configurato una chiave OpenAI condivisa su questa istanza." }, "testProvider": "Prova il provider attivo", @@ -4277,6 +4285,7 @@ "subtitle": "Stato del sistema, utenti e impostazioni", "settingsSaveError": "Impossibile salvare le impostazioni", "systemStatus": "Stato del sistema", + "systemStatusDescription": "Stato di esecuzione dell’istanza: database, worker e conteggi.", "database": "Database", "databaseConnected": "Connesso", "databaseError": "Errore", @@ -4298,6 +4307,23 @@ "empty": "Campionamento avviato: i dati compaiono tra 1–2 minuti.", "loadError": "Impossibile caricare le metriche dell'host. Prova a ricaricare la pagina." }, + "aiServerKey": { + "title": "Chiave IA del server", + "description": "Una chiave del provider a livello di operatore su cui ogni account può ripiegare quando non è configurata una chiave personale.", + "byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.", + "consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.", + "statusConfigured": "Configurata", + "statusNotConfigured": "Non configurata", + "keyLabel": "Chiave API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Salvata ({preview}) — inserisci una nuova chiave per sostituirla", + "modelLabel": "Modello", + "baseUrlLabel": "URL di base", + "save": "Salva", + "remove": "Rimuovi chiave", + "saved": "Impostazioni della chiave IA del server salvate", + "error": "Salvataggio della chiave IA del server non riuscito" + }, "aiQuality": { "title": "Qualità delle analisi", "loading": "Caricamento riepilogo feedback...", @@ -4333,6 +4359,7 @@ "workerStopped": "Arrestato", "lastReminderCheck": "Ultimo controllo dei promemoria", "appSettings": "Impostazioni dell'applicazione", + "appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.", "registrationEnabled": "Consentire la registrazione", "registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.", "defaultLanguage": "Lingua predefinita", @@ -4463,6 +4490,7 @@ "revokeError": "Impossibile revocare l'invito." }, "userManagement": "Gestione utenti", + "userManagementDescription": "Gestisci gli account: ruoli, reset delle password e disconnessioni forzate.", "userName": "Nome", "userEmail": "E-mail", "userRole": "Ruolo", @@ -4484,6 +4512,7 @@ "reset": "Reimposta", "loadingUsers": "Caricamento utenti...", "apiTokens": "Token API", + "apiTokensDescription": "Tutti i token API attivi e revocati di tutti gli account.", "tokenUser": "Utente", "tokenName": "Nome", "tokenPermissions": "Autorizzazioni", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "Ancora nessun token API", "tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.", "loginOverview": "Panoramica degli accessi", + "loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.", "allAuthEvents": "Tutti gli eventi di autenticazione", "failedOnly": "Solo non riusciti", "loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.", diff --git a/messages/pl.json b/messages/pl.json index 3c2de5a51..48785c4f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1610,6 +1610,12 @@ "addEra": "Dodaj wcześniejszy okres", "addEraTitle": "Dodaj wcześniejszy okres", "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany — np. zanim zmieniono go tutaj.", + "editAria": "Edytuj okres", + "editEraTitle": "Edytuj okres", + "editEraDescription": "Dostosuj godziny przyjmowania i zakres dat tego okresu.", + "archivedEditHint": "Ten okres został zapisany automatycznie — Twoja zmiana koryguje zarejestrowaną historię; oryginał pozostaje wewnętrznie jako zapis.", + "updatedToast": "Okres zaktualizowany", + "updateError": "Nie udało się zaktualizować okresu. Nie może nakładać się na inne okresy i musi kończyć się przed aktualnym planem.", "timesLabel": "Godziny przyjmowania", "fromLabel": "Od", "untilLabel": "Do", @@ -2128,6 +2134,8 @@ "settingsVerbosityBrief": "Krótka", "settingsVerbosityDefault": "Standardowa", "settingsVerbosityDetailed": "Szczegółowa", + "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).", + "settingsSourcesPointerLink": "Otwórz coacha", "settingsExcludeLabel": "Wyklucz metryki", "settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.", "settingsEvidenceLabel": "Pokazuj dowody domyślnie", @@ -3507,7 +3515,7 @@ "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", "integrationsHintLink": "Otwórz integracje", "cardTitle": "Priorytet źródeł według metryki", - "help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.", + "help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne. Pozostałe pozostają zapisane na potrzeby audytu.", "resetDefaults": "Przywróć wartości domyślne", "moveUp": "W górę", "moveDown": "W dół", @@ -3867,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI administratora", - "body": "Klucz OpenAI udostępniony przez operatora. Używany jako ostateczność, gdy nie skonfigurowano żadnego osobistego dostawcy. W tym wierszu nie ma nic do skonfigurowania — jego widoczność oznacza, że operator go skonfigurował.", + "body": "Klucz OpenAI udostępniony przez operatora, używany jako ostateczność bez osobistego dostawcy. Nie ma tu nic do skonfigurowania.", "notConfigured": "Operator nie skonfigurował współdzielonego klucza OpenAI w tej instancji." }, "testProvider": "Przetestuj aktywnego dostawcę", @@ -4277,6 +4285,7 @@ "subtitle": "Stan systemu, użytkownicy i ustawienia", "settingsSaveError": "Nie udało się zapisać ustawień", "systemStatus": "Stan systemu", + "systemStatusDescription": "Stan działania tej instancji: baza danych, worker i liczniki rekordów.", "database": "Baza danych", "databaseConnected": "Połączona", "databaseError": "Błąd", @@ -4298,6 +4307,23 @@ "empty": "Rozpoczęto próbkowanie — dane pojawią się w ciągu 1–2 minut.", "loadError": "Nie udało się załadować metryk hosta. Spróbuj odświeżyć stronę." }, + "aiServerKey": { + "title": "Serwerowy klucz AI", + "description": "Klucz dostawcy na poziomie operatora, z którego może skorzystać każde konto, gdy nie skonfigurowano klucza osobistego.", + "byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.", + "consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.", + "statusConfigured": "Skonfigurowany", + "statusNotConfigured": "Nieskonfigurowany", + "keyLabel": "Klucz API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Zapisany ({preview}) — wpisz nowy klucz, aby go zastąpić", + "modelLabel": "Model", + "baseUrlLabel": "Adres bazowy URL", + "save": "Zapisz", + "remove": "Usuń klucz", + "saved": "Zapisano ustawienia serwerowego klucza AI", + "error": "Nie udało się zapisać serwerowego klucza AI" + }, "aiQuality": { "title": "Jakość analiz", "loading": "Ładowanie podsumowania opinii...", @@ -4333,6 +4359,7 @@ "workerStopped": "Zatrzymano", "lastReminderCheck": "Ostatnie sprawdzenie przypomnień", "appSettings": "Ustawienia aplikacji", + "appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.", "registrationEnabled": "Zezwól na rejestrację", "registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.", "defaultLanguage": "Domyślny język", @@ -4463,6 +4490,7 @@ "revokeError": "Nie udało się unieważnić zaproszenia." }, "userManagement": "Zarządzanie użytkownikami", + "userManagementDescription": "Zarządzaj kontami: role, resety haseł i wymuszone wylogowania.", "userName": "Nazwa", "userEmail": "E-mail", "userRole": "Rola", @@ -4484,6 +4512,7 @@ "reset": "Resetuj", "loadingUsers": "Ładowanie użytkowników...", "apiTokens": "Tokeny API", + "apiTokensDescription": "Wszystkie aktywne i unieważnione tokeny API ze wszystkich kont.", "tokenUser": "Użytkownik", "tokenName": "Nazwa", "tokenPermissions": "Uprawnienia", @@ -4496,6 +4525,7 @@ "tokensEmptyTitle": "Brak tokenów API", "tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.", "loginOverview": "Przegląd logowań", + "loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.", "allAuthEvents": "Wszystkie zdarzenia uwierzytelniania", "failedOnly": "Tylko nieudane", "loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.", diff --git a/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql b/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql new file mode 100644 index 000000000..3b25de727 --- /dev/null +++ b/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql @@ -0,0 +1,14 @@ +-- v1.16.6 — audit trail for corrected schedule archives. +-- +-- The Zeitplan tab now lets the owner CORRECT a recorded era. A MANUAL +-- era is edited in place; an ARCHIVED era (minted by the write path) +-- stays immutable — the correction is written as a new MANUAL row and +-- this column on the original points at it. Every era consumer (the +-- splitter, the list endpoint, live-boundary reads) skips superseded +-- rows, so the correction takes the original's place while the +-- original remains as the audit record. +-- +-- Additive + non-destructive: NULL means "active era", which is what +-- every existing row is. +ALTER TABLE "medication_schedule_revisions" + ADD COLUMN "superseded_by_revision_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75f2921e7..2809aeaea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1475,6 +1475,14 @@ model MedicationScheduleRevision { /// era via POST /api/medications/{id}/schedule-revisions (deletable by /// the owner). source String @default("ARCHIVED") + /// v1.16.6 — audit trail for corrected archives. When the owner + /// corrects an ARCHIVED era through PATCH + /// /api/medications/{id}/schedule-revisions/{revisionId}, the route + /// mints a MANUAL replacement and points this column at it. The + /// original row stays as the audit record; every era consumer + /// (splitter, list endpoint, live-boundary reads) skips rows where + /// this is set. + supersededByRevisionId String? @map("superseded_by_revision_id") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f7bb0234e..4b49212d2 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { AboutSection } from "@/components/settings/about-section"; import { AiQualitySection } from "@/components/admin/ai-quality-section"; +import { AiServerKeySection } from "@/components/admin/ai-server-key-section"; import { AssistantSection } from "@/components/admin/assistant-section"; import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"; import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; @@ -104,6 +105,7 @@ export function AdminSectionRenderer({ subtitle={t("admin.section.assistant.subtitle")} > + ); case "coach-feedback": diff --git a/src/app/api/admin/ai-settings/__tests__/route.test.ts b/src/app/api/admin/ai-settings/__tests__/route.test.ts new file mode 100644 index 000000000..1ccb38c55 --- /dev/null +++ b/src/app/api/admin/ai-settings/__tests__/route.test.ts @@ -0,0 +1,217 @@ +/** + * v1.16.6 — admin CRUD contract for the operator-wide AI key + * (`/api/admin/ai-settings`). The endpoint predates its UI; the new + * admin "AI server key" card now drives it, so the contract gets + * pinned: admin-only access, encrypted storage, key removal, the + * HTTPS + hostname allowlist on the base URL, and the masked + * `keyPreview` in every response. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `enc(${value})`), + decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { encrypt } from "@/lib/crypto"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, +}; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/admin/ai-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 60_000), + } as never); +}); + +describe("GET /api/admin/ai-settings", () => { + it("propagates the admin gate (non-admin cannot read)", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(GET()).rejects.toMatchObject({ statusCode: 403 }); + }); + + it("returns defaults + hasKey=false when nothing is configured", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + const res = await GET(); + const body = await res.json(); + expect(body.data).toEqual({ + hasKey: false, + keyPreview: null, + model: "gpt-4o", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("returns hasKey=true with a masked preview when a key is stored", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: "enc(sk-test-12cd)", + adminAiModel: "gpt-4o-mini", + adminAiBaseUrl: "https://api.openai.com/v1", + } as never); + const res = await GET(); + const body = await res.json(); + expect(body.data.hasKey).toBe(true); + expect(body.data.keyPreview).toBe("...12cd"); + expect(body.data.model).toBe("gpt-4o-mini"); + // The plaintext key never leaks into the envelope. + expect(JSON.stringify(body)).not.toContain("sk-test-12cd"); + }); +}); + +describe("PUT /api/admin/ai-settings", () => { + function upsertEcho() { + vi.mocked(prisma.appSettings.upsert).mockImplementation( + (async (args: { update: Record }) => ({ + adminAiKeyEncrypted: null, + adminAiModel: "gpt-4o", + adminAiBaseUrl: "https://api.openai.com/v1", + ...args.update, + })) as never, + ); + } + + it("propagates the admin gate (non-admin cannot write)", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it("encrypts and stores a new key, audit-logged", async () => { + upsertEcho(); + const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" })); + const body = await res.json(); + + expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab"); + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" }, + }), + ); + expect(body.data.hasKey).toBe(true); + expect(body.data.keyPreview).toBe("...89ab"); + expect(auditLog).toHaveBeenCalledWith( + "admin.ai-settings.update", + expect.objectContaining({ userId: "admin-1" }), + ); + // The audit detail records THAT the key changed, never the key. + const details = vi.mocked(auditLog).mock.calls[0][1] as { + details: Record; + }; + expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab"); + }); + + it("clears the key when apiKey is empty", async () => { + upsertEcho(); + const res = await PUT(jsonReq({ apiKey: "" })); + const body = await res.json(); + + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: { adminAiKeyEncrypted: null } }), + ); + expect(body.data.hasKey).toBe(false); + expect(body.data.keyPreview).toBeNull(); + }); + + it("updates model and baseUrl for an allowlisted https host", async () => { + upsertEcho(); + const res = await PUT( + jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }), + ); + const body = await res.json(); + expect(body.data.model).toBe("gpt-4o-mini"); + expect(body.data.baseUrl).toBe("https://openrouter.ai/v1"); + }); + + it("rejects a plain-http base URL", async () => { + await expect( + PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })), + ).rejects.toMatchObject({ statusCode: 422 }); + }); + + it("rejects a base URL host outside the allowlist", async () => { + await expect( + PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })), + ).rejects.toMatchObject({ statusCode: 422 }); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("rejects an empty model", async () => { + await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({ + statusCode: 422, + }); + }); + + it("rejects a body with no recognised fields", async () => { + await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({ + statusCode: 422, + }); + }); + + it("returns 429 when the admin surface is rate-limited", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({ + statusCode: 429, + }); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 541c4b6c6..73bf1c728 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise { { status: 422 }, ); } - const { conversationId, message, locale: bodyLocale, scope } = parsed.data; + const { + conversationId, + message, + locale: bodyLocale, + scope, + guidedQuestion, + } = parsed.data; // Per-user request-rate ceiling layered in front of the daily budget // gate. The budget catches the cost dimension; this catches the @@ -206,7 +212,14 @@ async function handleChatRequest(request: NextRequest): Promise { }); // ── Refusal short-circuit ──────────────────────────────────── - const refusal = detectRefusal({ message, locale }); + // v1.16.6 — the guided-flow question rides the prompt too, so it + // runs through the same regex bank as the message. The questions + // are server-derived in the honest case; this guards the dishonest + // one (a crafted client using the field as an unchecked channel). + const refusal = detectRefusal({ + message: guidedQuestion ? `${guidedQuestion}\n${message}` : message, + locale, + }); if (refusal.refuse && refusal.message) { annotate({ action: { name: "insights.coach.refused" }, @@ -358,9 +371,20 @@ async function handleChatRequest(request: NextRequest): Promise { const transcript = window .map((t) => `${t.role.toUpperCase()}: ${t.content}`) .join("\n\n"); + // v1.16.6 — guided clarifying-questions flow: the question the user + // is answering exists only as a client-side bubble, so hand it to + // the model as delimited context. The reaction should read as a + // natural reply to the answer, not as a re-ask. + const guidedBlock = guidedQuestion + ? `\nGUIDED QUESTION (user-provided context) +The user's message answers this clarifying question from their self-context questionnaire: +"""${guidedQuestion}""" +React briefly and personally to the answer; do not repeat the question and do not ask it again. +` + : ""; const userPrompt = `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} - +${guidedBlock} CONVERSATION ${transcript} diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 9b6517294..cbca72a9d 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -143,7 +143,9 @@ export const GET = apiHandler( // the LIVE schedule rows, so it must not reach past the newest // revision boundary into a previous era's cadence. const latestRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id }, + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medicationId: id, supersededByRevisionId: null }, orderBy: { validUntil: "desc" }, select: { validUntil: true }, }); @@ -424,7 +426,9 @@ export const PUT = apiHandler( ) ) { const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id }, + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, orderBy: { validUntil: "desc" }, select: { validUntil: true }, }); diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index bd8d1d970..10ead9ff6 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -1,11 +1,22 @@ /** * v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * v1.16.6 — PATCH /api/medications/[id]/schedule-revisions/[revisionId] * - * Removes a MANUAL schedule era (one the owner appended through the - * sibling POST). Write-path archives (`source: "ARCHIVED"`) are - * immutable history — the wholesale-replace path minted them from rows - * that actually existed, so deleting one would falsify the ledger; the - * route refuses with 409. + * DELETE removes a MANUAL schedule era (one the owner appended through + * the sibling POST, or a correction minted by PATCH). Write-path + * archives (`source: "ARCHIVED"`) are immutable history — the + * wholesale-replace path minted them from rows that actually existed, + * so deleting one would falsify the ledger; the route refuses with 409. + * Deleting a correction restores the archived original it superseded. + * + * PATCH corrects an era. A MANUAL era updates in place. An ARCHIVED era + * stays untouched as the audit record: the correction is minted as a + * new MANUAL revision and the original's `supersededByRevisionId` + * points at it, so every era consumer reads the correction while the + * recorded history remains inspectable. Validation mirrors the sibling + * POST (bounds order, live-plan ceiling, no overlap with other active + * eras); the check-then-write runs under a `FOR UPDATE` lock on the + * medication row so concurrent era writes serialise. * * Auth: requireAuth() + medication ownership; a revision belonging to * another medication (or another user's medication) surfaces as 404, @@ -17,12 +28,246 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; +import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, revisionId } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const validFrom = new Date(parsed.data.validFrom); + const validUntil = new Date(parsed.data.validUntil); + + // Sanity floor — mirrors the sibling POST. + if (validFrom.getUTCFullYear() < 1900) { + return apiError("validFrom must be a date after 1900", 422); + } + + // Corrected snapshot, shaped exactly like the POST path: daily + // recurrence at the (schema-deduped, sorted) times, window pulled + // to their min/max. + const times = parsed.data.timesOfDay; + const entry = toRevisionPayloadEntry({ + timesOfDay: times, + windowStart: times[0], + windowEnd: times[times.length - 1], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }); + + // Validate-then-write under the per-medication row lock — the same + // serialisation as the sibling POST, so a concurrent era write can + // never slip an overlapping interval past the check. + const outcome = await prisma.$transaction(async (tx) => { + await tx.$queryRaw` + SELECT id FROM medications + WHERE id = ${id} + FOR UPDATE + `; + + const target = await tx.medicationScheduleRevision.findUnique({ + where: { id: revisionId }, + select: { + id: true, + medicationId: true, + source: true, + supersededByRevisionId: true, + validUntil: true, + }, + }); + if (!target || target.medicationId !== id) { + return { + ok: false as const, + status: 404 as const, + error: "Schedule revision not found", + }; + } + if (target.supersededByRevisionId !== null) { + return { + ok: false as const, + status: 409 as const, + error: "This era has already been corrected", + }; + } + + const others = await tx.medicationScheduleRevision.findMany({ + where: { + medicationId: id, + supersededByRevisionId: null, + id: { not: revisionId }, + }, + select: { validFrom: true, validUntil: true }, + }); + + // Live-plan ceiling, mirroring the POST rule: the live era began + // at the newest active `validUntil` (never earlier than + // `createdAt`). The edited era's own recorded end counts — an + // ARCHIVED era adjacent to the live plan may keep its boundary, + // but no correction may extend into tracked live history. + const liveBoundary = [target.validUntil, ...others.map((r) => r.validUntil)].reduce( + (latest, v) => (v > latest ? v : latest), + med.createdAt, + ); + if (validUntil.getTime() > liveBoundary.getTime()) { + return { + ok: false as const, + status: 422 as const, + error: "A corrected era must end before the current plan begins", + }; + } + + // No overlap with any OTHER active interval `[validFrom, + // validUntil)` — the corrected bounds may of course cover the + // era's own previous interval. + const overlaps = others.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return { + ok: false as const, + status: 422 as const, + error: "The era overlaps an existing schedule era", + }; + } + + const revisionSelect = { + id: true, + validFrom: true, + validUntil: true, + source: true, + } as const; + + if (target.source === "MANUAL") { + const revision = await tx.medicationScheduleRevision.update({ + where: { id: revisionId }, + data: { + validFrom, + validUntil, + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: revisionSelect, + }); + return { ok: true as const, revision, mode: "in_place" as const }; + } + + // ARCHIVED — immutable. Mint the correction as a MANUAL row and + // park the original behind it as the audit record. + const revision = await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: revisionSelect, + }); + await tx.medicationScheduleRevision.update({ + where: { id: revisionId }, + data: { supersededByRevisionId: revision.id }, + }); + return { ok: true as const, revision, mode: "supersede" as const }; + }); + + if (!outcome.ok) { + return apiError(outcome.error, outcome.status); + } + + await auditLog("medication.schedule_revision.updated", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + revisionId, + mode: outcome.mode, + ...(outcome.mode === "supersede" && { + correctionRevisionId: outcome.revision.id, + }), + }, + }); + + annotate({ + action: { + name: + outcome.mode === "in_place" + ? "medication.schedule_revision.manual_updated" + : "medication.schedule_revision.archived_corrected", + entity_type: "medication", + entity_id: id, + }, + meta: { + revision_id: outcome.revision.id, + ...(outcome.mode === "supersede" && { + superseded_revision_id: revisionId, + }), + }, + }); + + // The corrected era re-segments history; refresh the pre-aggregated + // compliance rollups asynchronously (best-effort). + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess({ + id: outcome.revision.id, + validFrom: outcome.revision.validFrom.toISOString(), + validUntil: outcome.revision.validUntil.toISOString(), + source: outcome.revision.source, + entries: [ + { + timesOfDay: times, + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ], + }); + }, +); + export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -46,9 +291,18 @@ export const DELETE = apiHandler( ); } - await prisma.medicationScheduleRevision.delete({ - where: { id: revisionId }, - }); + // Deleting a correction restores the archived original it had + // superseded — the audit record becomes the era again, atomically + // with the delete. + await prisma.$transaction([ + prisma.medicationScheduleRevision.delete({ + where: { id: revisionId }, + }), + prisma.medicationScheduleRevision.updateMany({ + where: { medicationId: id, supersededByRevisionId: revisionId }, + data: { supersededByRevisionId: null }, + }), + ]); await auditLog("medication.schedule_revision.deleted", { userId: user.id, diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts index d2c02ed12..adcf90a9a 100644 --- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -3,6 +3,7 @@ * * GET /api/medications/[id]/schedule-revisions * POST /api/medications/[id]/schedule-revisions + * PATCH /api/medications/[id]/schedule-revisions/[revisionId] * DELETE /api/medications/[id]/schedule-revisions/[revisionId] * * The POST guards are the contract under test: a manual era must end @@ -10,12 +11,16 @@ * archived era, and is always minted with `source: "MANUAL"`. The * DELETE refuses write-path archives (409) so the immutable history * the replace path mints can never be falsified through the API. + * PATCH (v1.16.6) corrects an era: MANUAL updates in place, ARCHIVED + * stays as the audit record behind a superseding MANUAL correction; + * both write paths run inside the per-medication `FOR UPDATE` + * transaction so concurrent era writes serialise. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { +vi.mock("@/lib/db", () => { + const prisma: Record = { medication: { findUnique: vi.fn(), }, @@ -23,10 +28,15 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn(), create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), delete: vi.fn(), }, - }, -})); + $queryRaw: vi.fn(), + }; + prisma.$transaction = vi.fn(); + return { prisma }; +}); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -51,7 +61,7 @@ vi.mock("next/headers", () => ({ })); import { GET, POST } from "../route"; -import { DELETE } from "../[revisionId]/route"; +import { DELETE, PATCH } from "../[revisionId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; @@ -91,11 +101,30 @@ function deleteCtx(revisionId: string) { return { params: Promise.resolve({ id: "m1", revisionId }) }; } +function patchReq(revisionId: string, body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z"); beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Interactive form runs the callback against the same mock client + // (the routes only touch models the flat mock already carries); the + // array form resolves the batched operations like Promise.all. + vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) => + typeof arg === "function" + ? (arg as (tx: typeof prisma) => unknown)(prisma) + : Promise.all(arg as Array>)) as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); // Ownership guard + GET both read `medication.findUnique`; the guard // selects `{ id, userId }`, the GET selects `{ createdAt }` — one // superset row satisfies both. @@ -216,21 +245,53 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { }); it("422s when the era ends after the live plan began", async () => { + // Realistic archive: the plan was replaced on 2026-03-01, after + // tracking began (createdAt 2026-01-10) — the live plan has run + // since 2026-03-01. + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: MED_CREATED_AT, + validUntil: new Date("2026-03-01T00:00:00Z"), + }, + ] as never); + // Ends 2026-04-01 — after the live plan start — and does NOT + // overlap the archived era; the boundary check must fire. + const res = await POST( + postReq({ + ...VALID_BODY, + validFrom: "2026-03-05T00:00:00.000Z", + validUntil: "2026-04-01T00:00:00.000Z", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("accepts a manual era filling the gap between an older manual era and createdAt", async () => { + // Two-chunk pre-tracking history entered oldest-first: the second + // chunk ends after the first chunk's validUntil but still at or + // before createdAt. The boundary is max(createdAt, newest active + // validUntil) — order of entry must not matter. vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ { validFrom: new Date("2025-01-01T00:00:00Z"), validUntil: new Date("2025-04-01T00:00:00Z"), }, ] as never); - // Ends 2025-06-01 — after the live plan start (2025-04-01) — and - // does NOT overlap the archived era; the boundary check must fire. + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date("2025-04-01T00:00:00.000Z"), + validUntil: new Date("2025-06-01T00:00:00.000Z"), + source: "MANUAL", + payload: [], + } as never); const res = await POST( postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }), ROUTE_CTX, ); - expect(res.status).toBe(422); - const json = await res.json(); - expect(json.error).toMatch(/current plan/i); + expect(res.status).toBe(201); }); it("422s when no revision exists and the era reaches past createdAt", async () => { @@ -292,6 +353,34 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( "user-1", ); + + // Check-then-insert ran inside the transaction with the + // per-medication `FOR UPDATE` lock taken first. + expect(prisma.$transaction).toHaveBeenCalled(); + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); + + it("collapses duplicate timesOfDay before snapshotting", async () => { + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date(VALID_BODY.validFrom), + validUntil: new Date(VALID_BODY.validUntil), + source: "MANUAL", + payload: [], + } as never); + + const res = await POST( + postReq({ ...VALID_BODY, timesOfDay: ["19:00", "07:00", "19:00"] }), + ROUTE_CTX, + ); + expect(res.status).toBe(201); + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { payload: Array<{ timesOfDay: string[] }> }; + }; + // The Zod transform dedupes + sorts — a double-tapped chip must + // never mint a double-counted slot. + expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]); }); }); @@ -322,7 +411,7 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); }); - it("deletes a MANUAL era and enqueues the rollup backfill", async () => { + it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => { vi.mocked( prisma.medicationScheduleRevision.findUnique, ).mockResolvedValue({ @@ -333,6 +422,9 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue( {} as never, ); + vi.mocked( + prisma.medicationScheduleRevision.updateMany, + ).mockResolvedValue({ count: 0 } as never); const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m")); expect(res.status).toBe(200); const json = await res.json(); @@ -340,6 +432,164 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({ where: { id: "rev-m" }, }); + // If the deleted row was a correction, the archived original it + // superseded becomes the era again. + expect(prisma.medicationScheduleRevision.updateMany).toHaveBeenCalledWith({ + where: { medicationId: "m1", supersededByRevisionId: "rev-m" }, + data: { supersededByRevisionId: null }, + }); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); + +describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { + const PATCH_BODY = { + validFrom: "2025-03-12T00:00:00.000Z", + validUntil: "2025-06-01T00:00:00.000Z", + timesOfDay: ["08:00", "20:00", "08:00"], + }; + + function mockTarget(overrides: Record = {}) { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-t", + medicationId: "m1", + source: "MANUAL", + supersededByRevisionId: null, + validUntil: new Date("2025-06-01T00:00:00Z"), + ...overrides, + } as never); + } + + it("404s when the revision belongs to another medication", async () => { + mockTarget({ medicationId: "other-med" }); + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled(); + }); + + it("409s when the era has already been corrected", async () => { + mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" }); + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(409); + expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled(); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s when the corrected era overlaps another active era", async () => { + mockTarget(); + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-06-01T00:00:00Z"), + validUntil: new Date("2025-08-01T00:00:00Z"), + }, + ] as never); + const res = await PATCH( + patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/overlap/i); + }); + + it("422s when the correction extends past the live boundary", async () => { + // ARCHIVED era ending 2025-06-01, no other revisions; the live + // ceiling is max(own recorded end, createdAt 2026-01-10) — a + // correction reaching past it would swallow tracked live history. + mockTarget({ source: "ARCHIVED" }); + const res = await PATCH( + patchReq("rev-t", { + ...PATCH_BODY, + validUntil: "2026-02-01T00:00:00.000Z", + }), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("updates a MANUAL era in place with the deduped snapshot", async () => { + mockTarget(); + vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue({ + id: "rev-t", + validFrom: new Date(PATCH_BODY.validFrom), + validUntil: new Date(PATCH_BODY.validUntil), + source: "MANUAL", + } as never); + + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.id).toBe("rev-t"); + expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]); + + const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update) + .mock.calls[0][0] as unknown as { + where: { id: string }; + data: { payload: Array<{ timesOfDay: string[]; rrule: string }> }; + }; + expect(updateArgs.where.id).toBe("rev-t"); + expect(updateArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]); + expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); + // In-place — no superseding row is minted. + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + // Check-then-write ran inside the locked transaction. + expect(prisma.$transaction).toHaveBeenCalled(); + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); + + it("corrects an ARCHIVED era via a superseding MANUAL revision", async () => { + mockTarget({ source: "ARCHIVED" }); + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-fix", + validFrom: new Date(PATCH_BODY.validFrom), + validUntil: new Date(PATCH_BODY.validUntil), + source: "MANUAL", + } as never); + vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue( + {} as never, + ); + + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(200); + const json = await res.json(); + // The response carries the correction, which now takes the + // original's place in every era read. + expect(json.data.id).toBe("rev-fix"); + expect(json.data.source).toBe("MANUAL"); + + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { source: string; payload: Array<{ timesOfDay: string[] }> }; + }; + expect(createArgs.data.source).toBe("MANUAL"); + expect(createArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]); + // The ARCHIVED original is parked behind the correction, not + // touched in its recorded content. + expect(prisma.medicationScheduleRevision.update).toHaveBeenCalledWith({ + where: { id: "rev-t" }, + data: { supersededByRevisionId: "rev-fix" }, + }); expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( "user-1", ); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index c7cd2075e..9eda687b5 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -86,7 +86,9 @@ export const GET = apiHandler( } const revisions = await prisma.medicationScheduleRevision.findMany({ - where: { medicationId: id }, + // Superseded rows are audit records behind a correction — the + // timeline shows the corrected state, like every era consumer. + where: { medicationId: id, supersededByRevisionId: null }, orderBy: { validFrom: "desc" }, select: { id: true, @@ -164,46 +166,10 @@ export const POST = apiHandler( return apiError("validFrom must be a date after 1900", 422); } - const existing = await prisma.medicationScheduleRevision.findMany({ - where: { medicationId: id }, - select: { validFrom: true, validUntil: true }, - }); - - // "Before the current plan": the live era began at the newest - // archived `validUntil`; with no archive the live plan has covered - // everything since the medication was created (the era splitter - // reads `[newest validUntil, ∞)` — or the whole range when no - // revision exists), so a manual era must end at or before - // `createdAt`. Allowing it to reach "now" would let the manual - // snapshot swallow tracked live history and re-score compliance - // against the wrong plan. - const liveStart = existing.reduce( - (latest, r) => (r.validUntil > latest ? r.validUntil : latest), - new Date(0), - ); - const liveBoundary = existing.length > 0 ? liveStart : med.createdAt; - if (validUntil.getTime() > liveBoundary.getTime()) { - return apiError( - "A manual era must end before the current plan begins", - 422, - ); - } - - // No overlap with any archived interval `[validFrom, validUntil)`. - const overlaps = existing.some( - (r) => - validFrom.getTime() < r.validUntil.getTime() && - validUntil.getTime() > r.validFrom.getTime(), - ); - if (overlaps) { - return apiError("The era overlaps an existing schedule era", 422); - } - // Snapshot entry shaped exactly like the write-path archive: daily // recurrence at the given times, window pulled to their min/max. - const times = [...parsed.data.timesOfDay].sort((a, b) => - a.localeCompare(b), - ); + // The schema already deduped + sorted the times. + const times = parsed.data.timesOfDay; const entry = toRevisionPayloadEntry({ timesOfDay: times, windowStart: times[0], @@ -220,23 +186,77 @@ export const POST = apiHandler( reminderGraceMinutes: null, }); - const revision = await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom, - validUntil, - source: "MANUAL", - payload: [entry] as unknown as Prisma.InputJsonValue, - }, - select: { - id: true, - validFrom: true, - validUntil: true, - source: true, - payload: true, - }, + // Validate-then-write under a per-medication row lock: two + // concurrent era writes used to both pass the overlap check against + // the same snapshot and both insert. `FOR UPDATE` on the medication + // row serialises every era write for this medication; the reads run + // through the same `tx` so they see the locked state. + const outcome = await prisma.$transaction(async (tx) => { + await tx.$queryRaw` + SELECT id FROM medications + WHERE id = ${id} + FOR UPDATE + `; + + const existing = await tx.medicationScheduleRevision.findMany({ + where: { medicationId: id, supersededByRevisionId: null }, + select: { validFrom: true, validUntil: true }, + }); + + // "Before the current plan": the live era began at the newest + // active `validUntil`, and never earlier than `createdAt` — the + // era splitter reads `[newest validUntil, ∞)`, or the whole range + // when no revision exists. Allowing a manual era to reach past + // that instant would let the manual snapshot swallow tracked live + // history and re-score compliance against the wrong plan. + const liveBoundary = existing.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + med.createdAt, + ); + if (validUntil.getTime() > liveBoundary.getTime()) { + return { + ok: false, + error: "A manual era must end before the current plan begins", + } as const; + } + + // No overlap with any active interval `[validFrom, validUntil)`. + const overlaps = existing.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return { + ok: false, + error: "The era overlaps an existing schedule era", + } as const; + } + + const revision = await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + return { ok: true, revision } as const; }); + if (!outcome.ok) { + return apiError(outcome.error, 422); + } + const revision = outcome.revision; + await auditLog("medication.schedule_revision.created", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 2526095a9..ee3f6b32d 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -99,7 +99,9 @@ async function buildMedicationsList( // past this boundary into a previous era's cadence. prisma.medicationScheduleRevision.groupBy({ by: ["medicationId"], - where: { medication: { userId } }, + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medication: { userId }, supersededByRevisionId: null }, _max: { validUntil: true }, }), ]); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 25b3f7998..af70c3348 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -14,6 +14,8 @@ import { describePasskeyError } from "@/lib/passkey-errors"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; +import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; export default function LoginPage() { const router = useRouter(); @@ -58,6 +60,18 @@ export default function LoginPage() { return "/"; } + // v1.16.6 first-load waterfall fix — the session cookie exists the + // moment the login POST resolves, so the dashboard snapshot request + // can ride in parallel with the route transition + page-chunk + // download instead of queueing behind the dashboard mount. + function navigateAfterLogin() { + const target = getRedirectTarget(); + if (target === "/" && isDashboardSnapshotEnabled()) { + prefetchDashboardSnapshot(queryClient); + } + router.push(target); + } + async function handlePasskeyLogin() { setError(null); setLoading(true); @@ -77,7 +91,7 @@ export default function LoginPage() { }); await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push(getRedirectTarget()); + navigateAfterLogin(); } catch (err) { // Route rejections carry the envelope error verbatim; everything // else (WebAuthn ceremony failures) keeps the descriptive mapping. @@ -101,7 +115,7 @@ export default function LoginPage() { await apiPost("/api/auth/login", { email, password }); await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push(getRedirectTarget()); + navigateAfterLogin(); } catch (err) { setError( err instanceof ApiError && err.message diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a186d4541..259179bad 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -15,6 +15,7 @@ import { orderedVisibleSectionIds, type InsightsSectionId, } from "@/lib/insights-layout"; +import { useMounted } from "@/hooks/use-mounted"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; @@ -221,6 +222,7 @@ interface ComprehensiveData { export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); + const mounted = useMounted(); const { t } = useTranslations(); // v1.4.33 IW9 — scroll-to-top on route mount centralised in the @@ -340,8 +342,12 @@ export default function InsightsPage() { ); } + // Gated on `mounted`: the auth query can resolve before this + // boundary hydrates, and a greeting that personalises during the + // hydration render disagrees with the name-less SSR text — React + // #418. See `useMounted`. const heroGreetingName = - user?.username?.trim() && user.username.trim().length > 0 + mounted && user?.username?.trim() && user.username.trim().length > 0 ? user.username.split(/\s+/)[0] : null; const briefingPayload = advisor.payload?.dailyBriefing ?? null; diff --git a/src/app/page.tsx b/src/app/page.tsx index 020a340e3..25e254d44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -591,7 +591,11 @@ export default function DashboardPage() { // changes when the user's timezone changes; recomputing it every // render churned the parts-array and walked the format ICU twice per // render across a 1 400-line component body. - const userTimezone = user?.timezone; + // Gated on `mounted`: `user` comes from the auth query, which can + // resolve before this boundary hydrates — the greeting text must + // match the name-less SSR output during hydration (React #418) and + // may only personalise from the first client re-render. + const userTimezone = mounted ? user?.timezone : undefined; const hour = useMemo( () => (userTimezone ? getHourForTimeZone(userTimezone) : null), [userTimezone], @@ -605,7 +609,7 @@ export default function DashboardPage() { ? t("dashboard.greeting.day") : t("dashboard.greeting.evening"); const welcomeText = - user?.username && user.username.trim().length > 0 + mounted && user?.username && user.username.trim().length > 0 ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name: user.username, diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index 166d35e6f..6f0ac9348 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -24,10 +24,12 @@ vi.mock("next/navigation", () => ({ // The shell gates its own frame on the confirmed ADMIN role — a // non-admin (or a still-loading auth state) must not see the section // nav while AuthShell's redirect effect is catching up. -const mockUserRef = { value: { role: "ADMIN", username: "op" } as { - role: string; - username: string; -} | null }; +const mockUserRef = { + value: { role: "ADMIN", username: "op" } as { + role: string; + username: string; + } | null, +}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: mockUserRef.value }), })); diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts index ab31f02da..d325e61bf 100644 --- a/src/components/admin/__tests__/login-overview-csv.test.ts +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => { }); it("folds Telefónica + O2 variants down to 'O2'", () => { - expect( - carrierShortLabel("Telefónica Germany GmbH & Co. OHG"), - ).toBe("O2"); + expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2"); expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2"); expect(carrierShortLabel("O2 Deutschland")).toBe("O2"); }); @@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => { it("returns the raw organisation string for unknown carriers", () => { expect(carrierShortLabel("Google LLC")).toBe("Google LLC"); expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc."); - expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH"); + expect(carrierShortLabel("Hetzner Online GmbH")).toBe( + "Hetzner Online GmbH", + ); }); }); diff --git a/src/components/admin/__tests__/user-management-responsive.test.tsx b/src/components/admin/__tests__/user-management-responsive.test.tsx index cdcc955f9..3dc75b13d 100644 --- a/src/components/admin/__tests__/user-management-responsive.test.tsx +++ b/src/components/admin/__tests__/user-management-responsive.test.tsx @@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => { expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); }); - it("stacks header rows on mobile so filter pills don't collide with title", () => { + it("wraps the filter pills below the title on narrow viewports", () => { const html = render(); - // Header carries `flex-col` at base + `sm:flex-row` at small+. - expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/); + // The shared SettingsCardHeader title row wraps, so the status slot + // (filter pills) reflows under the title instead of colliding with it. + expect(html).toMatch( + /class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/, + ); }); }); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 14ae3aa41..c24a6957c 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react"; import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input"; @@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [ "ARCHIVED", ]; +/** Shared green "configured" pill — the GlitchTip, Umami, bug-report, + * and Web-Push VAPID cards all surface the same state; one component + * keeps the tint and copy in lockstep. */ +export function ConfiguredBadge() { + const { t } = useTranslations(); + return ( + + {t("admin.configured")} + + ); +} + export function StatusItem({ icon: Icon, label, diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 9a583fedb..c8ce68f90 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -261,9 +261,9 @@ export function AdminShell({ active, children }: AdminShellProps) { aria-current={isActive ? "page" : undefined} className={cn( // v1.4.25 W8 — chip strip is the primary mobile-admin - // navigation surface. Pad to WCAG 2.5.5 44 px so the - // chips can be tapped without zoom on a Pixel-5. - "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", + // navigation surface. Pad to WCAG 2.5.5 44 px so the + // chips can be tapped without zoom on a Pixel-5. + "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", isActive ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 8529eb18a..4a026e59f 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -66,7 +67,7 @@ export function AiQualitySection() { if (query.isLoading) { return ( -
      +
      {t("admin.aiQuality.loading")} @@ -90,14 +91,12 @@ export function AiQualitySection() { if (!summary || summary.buckets.length === 0) { return ( -
      -
      - -
      - {t("admin.aiQuality.title")} -
      -
      -

      +

      + +

      {t("admin.aiQuality.empty")}

      @@ -105,23 +104,21 @@ export function AiQualitySection() { } return ( -
      -
      -
      - -
      - {t("admin.aiQuality.title")} -
      -
      -

      - {t("admin.aiQuality.windowLabel", { - days: String(summary.windowDays), - })} - {" · "} - {t("admin.aiQuality.generatedAtLabel")}{" "} - {fmt.dateTime(summary.generatedAt)} -

      -
      +
      + + {t("admin.aiQuality.windowLabel", { + days: String(summary.windowDays), + })} + {" · "} + {t("admin.aiQuality.generatedAtLabel")}{" "} + {fmt.dateTime(summary.generatedAt)} +

      + } + />
      diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx new file mode 100644 index 000000000..5057b824d --- /dev/null +++ b/src/components/admin/ai-server-key-section.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { PasswordInput } from "./_shared"; + +/** + * v1.16.6 — operator panel for the server-wide AI key. + * + * The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has + * existed since the `admin-openai` chain fallback shipped, but it never had + * an operator UI — configuring the shared key took a manual API call. This + * card is its home, beside the assistant feature flags. + * + * Semantics worth keeping visible to the operator: + * - A user's own key (BYOK / Codex OAuth / local) always wins; the server + * key sits at the END of the provider chain as the shared fallback. + * - Egress over the server key is consent-gated per user + * (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a + * key here never silently forwards anyone's health data. + * - The base URL is locked server-side to HTTPS + a hostname allowlist + * (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it). + */ + +interface AiServerKeyResponse { + hasKey: boolean; + keyPreview: string | null; + model: string; + baseUrl: string; +} + +export function AiServerKeySection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const { data: settings } = useQuery({ + queryKey: queryKeys.adminAiServerKey(), + queryFn: () => apiGet("/api/admin/ai-settings"), + }); + + const [keyDraft, setKeyDraft] = useState(""); + const [modelDraft, setModelDraft] = useState(null); + const [baseUrlDraft, setBaseUrlDraft] = useState(null); + + const modelValue = modelDraft ?? settings?.model ?? ""; + const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? ""; + const configured = settings?.hasKey ?? false; + + const save = useMutation({ + mutationFn: (payload: Record) => + apiPut("/api/admin/ai-settings", payload), + onSuccess: () => { + setKeyDraft(""); + setModelDraft(null); + setBaseUrlDraft(null); + queryClient.invalidateQueries({ + queryKey: queryKeys.adminAiServerKey(), + }); + toast.success(t("admin.aiServerKey.saved")); + }, + onError: (error: unknown) => { + toast.error( + error instanceof Error ? error.message : t("admin.aiServerKey.error"), + ); + }, + }); + + function handleSave() { + const payload: Record = { + model: modelValue, + baseUrl: baseUrlValue, + }; + if (keyDraft.trim().length > 0) { + payload.apiKey = keyDraft.trim(); + } + save.mutate(payload); + } + + function handleRemove() { + save.mutate({ apiKey: "" }); + } + + return ( +
      + +

      {t("admin.aiServerKey.description")}

      +

      {t("admin.aiServerKey.byokHint")}

      +

      {t("admin.aiServerKey.consentHint")}

      + + } + status={ + + {configured + ? t("admin.aiServerKey.statusConfigured") + : t("admin.aiServerKey.statusNotConfigured")} + + } + /> + +
      +
      + + setKeyDraft(e.target.value)} + placeholder={ + configured && settings?.keyPreview + ? t("admin.aiServerKey.keyPlaceholderSet", { + preview: settings.keyPreview, + }) + : t("admin.aiServerKey.keyPlaceholder") + } + autoComplete="off" + /> +
      + +
      +
      + + setModelDraft(e.target.value)} + placeholder="gpt-4o" + /> +
      +
      + + setBaseUrlDraft(e.target.value)} + placeholder="https://api.openai.com/v1" + /> +
      +
      + +
      + + {configured && ( + + )} +
      +
      +
      + ); +} diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 66631e0b6..114c2917f 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -11,6 +11,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; @@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() { return (
      -
      - -
      {t("admin.apiTokens")}
      -
      +
      {isLoading ? ( diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 84d71c62d..61a46d8a0 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -46,6 +46,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { WideEvent, LogLevel } from "@/lib/logging/types"; @@ -104,7 +105,9 @@ export function AppLogPreviewSection() { const { data, isLoading, refetch, isFetching } = useQuery({ queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range), queryFn: async () => { - return apiGet(`/api/admin/app-logs?${params.toString()}`); + return apiGet( + `/api/admin/app-logs?${params.toString()}`, + ); }, refetchInterval: 30_000, }); @@ -112,31 +115,25 @@ export function AppLogPreviewSection() { const events = data?.events ?? []; return ( -
      -
      -
      - -
      -

      - {t("admin.section.app-logs.title")} -

      -

      - {t("admin.section.app-logs.processNote")} -

      -
      -
      - -
      +
      + refetch()} + disabled={isFetching} + aria-label={t("admin.section.app-logs.refresh")} + > + + + } + />
      { - return apiGet("/api/admin/settings/assistant-flags"); + return apiGet( + "/api/admin/settings/assistant-flags", + ); }, }); } @@ -61,13 +64,13 @@ function useUpdateAssistantFlags() { mutationFn: async ( patch: Partial, ): Promise => { - return apiPut("/api/admin/settings/assistant-flags", patch); + return apiPut( + "/api/admin/settings/assistant-flags", + patch, + ); }, onSuccess: (data) => { - client.setQueryData( - queryKeys.adminAssistantFlags(), - data, - ); + client.setQueryData(queryKeys.adminAssistantFlags(), data); // Bust the runtime `/api/feature-flags` cache so the operator // sees the toggled surface react within the same session. client.invalidateQueries({ queryKey: queryKeys.featureFlags() }); @@ -96,73 +99,72 @@ export function AssistantSection() { const disabledSubs = !masterOn || mutation.isPending; return ( -
      -
      - -
      {t("admin.assistant.title")}
      -
      -

      - {t("admin.assistant.description")} -

      - - - mutation.mutate({ assistantEnabled: checked }) - } - disabled={mutation.isPending} +
      + +
      + + mutation.mutate({ assistantEnabled: checked }) + } + disabled={mutation.isPending} + /> -
      - - mutation.mutate({ assistantCoachEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantBriefingEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantInsightStatusEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantCorrelationsEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ - assistantHealthScoreExplainerEnabled: checked, - }) - } - disabled={disabledSubs} - /> +
      + + mutation.mutate({ assistantCoachEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantBriefingEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantInsightStatusEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantCorrelationsEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ + assistantHealthScoreExplainerEnabled: checked, + }) + } + disabled={disabledSubs} + /> +
      ); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 312d78910..138146e38 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; @@ -331,34 +332,34 @@ export function BackupsSection() { } return ( -
      -
      -
      - -
      - {t("admin.section.backups.title")} -
      - {data && ( +
      + {rows.length} - )} -
      - -
      -

      + ) : null + } + status={ + + } + /> +

      {t("admin.section.backups.description")}{" "} {/* External docs link — Phase E will publish the matching page on the docs site. `noopener noreferrer` because this leaves the diff --git a/src/components/admin/bug-report-section.tsx b/src/components/admin/bug-report-section.tsx index 5ab87bbf3..36c4c7275 100644 --- a/src/components/admin/bug-report-section.tsx +++ b/src/components/admin/bug-report-section.tsx @@ -2,13 +2,18 @@ import { useState } from "react"; import { Bug, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; -import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared"; +import { + ConfiguredBadge, + PasswordInput, + useAdminSettings, + useUpdateSettings, +} from "./_shared"; export function BugReportSection() { const { t } = useTranslations(); @@ -45,27 +50,15 @@ export function BugReportSection() { } return ( -

      -
      -
      - -

      - {t("admin.bugReportGithub")} -

      -
      -
      - {configured && ( - - {t("admin.configured")} - - )} -
      -
      -

      - {t("admin.bugReportGithubDescription")} -

      +
      + : null} + /> -
      +

      {t("admin.bugReportEnabledLabel")} diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 26e9a3cd0..c78619301 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, Sparkles } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -69,25 +70,23 @@ export function CoachFeedbackSection() { // /admin/coach-feedback. Mirrors the canonical structure used by // (header outside the fetch-state branch). return ( -

      -
      -
      - -
      - {t("admin.coachFeedback.title")} -
      -
      - {hasData && summary && ( -

      - {t("admin.coachFeedback.windowLabel", { - days: String(summary.windowDays), - })} - {" · "} - {t("admin.coachFeedback.generatedAtLabel")}{" "} - {fmt.dateTime(summary.generatedAt)} -

      - )} -
      +
      + + {t("admin.coachFeedback.windowLabel", { + days: String(summary.windowDays), + })} + {" · "} + {t("admin.coachFeedback.generatedAtLabel")}{" "} + {fmt.dateTime(summary.generatedAt)} +

      + ) : null + } + /> {query.isLoading ? (
      diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 41983cf92..93cebfdd3 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -59,7 +59,7 @@ export function DangerZoneSection() { }); return ( -
      +
      {/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*` already names the section, so the card-level title was a duplicate of the page title. The destructive icon stays so diff --git a/src/components/admin/feedback-inbox-section.tsx b/src/components/admin/feedback-inbox-section.tsx index cbda34295..246656a0d 100644 --- a/src/components/admin/feedback-inbox-section.tsx +++ b/src/components/admin/feedback-inbox-section.tsx @@ -54,7 +54,9 @@ export function FeedbackInboxSection() { const { data, isLoading } = useQuery({ queryKey: queryKeys.adminFeedback(activeStatus), queryFn: async () => { - return apiGet(`/api/admin/feedback?status=${activeStatus}&limit=100`); + return apiGet( + `/api/admin/feedback?status=${activeStatus}&limit=100`, + ); }, }); @@ -65,7 +67,7 @@ export function FeedbackInboxSection() { } return ( -
      +
      {/* v1.4.19 A8 / F-08 + F-20: this is the only card on `/admin/feedback`, so the page header already provides the title + description — the card-level repetition was just @@ -287,7 +289,9 @@ function FeedbackDetailDialog({ const publish = useMutation({ mutationFn: async () => { - return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`); + return apiPost<{ issueUrl: string }>( + `/api/admin/feedback/${item.id}/github`, + ); }, onSuccess: (data) => { setIssueUrl(data.issueUrl); diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index dcc5314c8..85244075c 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Settings } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { NativeSelect } from "@/components/ui/native-select"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; @@ -25,12 +26,13 @@ export function GeneralSettingsSection() { const currentDefaultTz = settings?.defaultUserTimezone ?? ""; return ( -
      -
      - -
      {t("admin.appSettings")}
      -
      -
      +
      + +
      -
      -
      - -

      {t("admin.glitchtipTitle")}

      -
      -
      - {configured && ( - - {t("admin.configured")} - - )} -
      -
      -

      - {t("admin.glitchtipDescription")} -

      +
      + : null} + /> -
      +
      -
      -

      +

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

      @@ -559,79 +562,79 @@ export function InviteTokensSection() { }} className="space-y-4" > -

      -
      - - {t("admin.invites.ttlLegend")} - -
      - {TTL_CHOICES.map((days) => ( - - ))} -
      -
      -
      - - setMaxUses(e.target.value)} - onBlur={() => { - // Echo the submit-time clamp (1–50, integer) into - // the field so the admin sees what will be minted. - setMaxUses(String(clampMaxUses(maxUses))); - }} - className="w-24 tabular-nums" - data-testid="admin-invites-max-uses" - /> -

      - {t("admin.invites.maxUsesHint")} -

      -
      -
      - - + ))} +
      + +
      + + setMaxUses(e.target.value)} + onBlur={() => { + // Echo the submit-time clamp (1–50, integer) into + // the field so the admin sees what will be minted. + setMaxUses(String(clampMaxUses(maxUses))); + }} + className="w-24 tabular-nums" + data-testid="admin-invites-max-uses" /> - ) : ( -
      +
      + + + ) : ( diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index a31532966..780f635f6 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toCSV } from "@/lib/export"; @@ -131,7 +132,9 @@ export function LoginOverviewSection() { if (filter === "failed") { params.set("action", "auth.login.failed"); } - return apiGet(`/api/admin/audit-log?${params.toString()}`); + return apiGet( + `/api/admin/audit-log?${params.toString()}`, + ); }, }); @@ -193,11 +196,12 @@ export function LoginOverviewSection() { } return ( -
      -
      - -
      {t("admin.loginOverview")}
      -
      +
      +
      {/* Quick-filter pills. Failed-only is the most common diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 138e223c0..486afb127 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; @@ -38,7 +39,9 @@ export function RecentAuditPreview() { const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { - return apiGet(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`); + return apiGet( + `/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`, + ); }, // Avoid hitting the DB on every page focus; the overview is the // type of page admins keep open and re-render frequently. @@ -52,26 +55,22 @@ export function RecentAuditPreview() { return (
      -
      -
      -
      - - {t("admin.overview.auditViewAll")} -
      + {t("admin.overview.auditViewAll")} +