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 (
- {/* 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 (
+
+ {Array.from({ length: total }, (_, i) => (
+ current && "bg-muted-foreground/25",
+ )}
+ />
+ ))}
+
+ );
+}
+
+export interface GuidedQuestionBubbleProps {
+ question: string;
+ progress: { current: number; total: number };
+ /** Live question: actions render and the typing reveal plays. */
+ current?: boolean;
+ onSkip?: () => void;
+ onLater?: () => void;
+ onDismissRemaining?: () => void;
+ /** Disables the actions (in-flight dismiss request). */
+ actionsDisabled?: boolean;
+}
+
+export function GuidedQuestionBubble({
+ question,
+ progress,
+ current = false,
+ onSkip,
+ onLater,
+ onDismissRemaining,
+ actionsDisabled = false,
+}: GuidedQuestionBubbleProps) {
+ const { t } = useTranslations();
+
+ // Scripted-bot rhythm: the current question holds the shared
+ // typing-dots indicator for a beat before the text reveals. SSR and
+ // reduced-motion users get the text immediately; answered bubbles
+ // never replay the reveal.
+ const [revealed, setRevealed] = useState(() => {
+ if (!current) return true;
+ if (typeof window === "undefined") return true;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ });
+ useEffect(() => {
+ if (revealed) return;
+ const handle = window.setTimeout(() => setRevealed(true), 650);
+ return () => window.clearTimeout(handle);
+ }, [revealed]);
+
+ return (
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {railVisible && (
+
+ )}
+ {children}
+
+ );
+}
+
+/**
+ * "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 (
+
+ );
+}
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 (
-
-
- 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"
- />
-
{/* 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.auditTitle")}
-
-
-
- {t("admin.overview.auditViewAll")}
-
-
-
+ {t("admin.overview.auditViewAll")}
+
+
+ }
+ />
{isLoading ? (
diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx
index a097a32a1..3f4449a43 100644
--- a/src/components/admin/reminders-section.tsx
+++ b/src/components/admin/reminders-section.tsx
@@ -8,6 +8,7 @@ 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 { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -91,21 +92,17 @@ export function RemindersSection() {
});
return (
-
-
- {/* v1.4.22 D / F-58 — icon swap so the section header matches
- the admin-shell nav tile (Bell). Clock now lives on the
- timing chips inline instead of competing as the page icon. */}
-
-
- {t("admin.medicationReminders")}
-
-
-
- {t("admin.medicationRemindersDescription")}
-
+
+ {/* v1.4.22 D / F-58 — icon swap so the section header matches
+ the admin-shell nav tile (Bell). Clock now lives on the
+ timing chips inline instead of competing as the page icon. */}
+
-