Merge pull request #312 from MBombeck/release/v1.16.6

Release v1.16.6 — corrected eras, an honest weekly status and a faster first paint
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-11 16:31:58 +02:00
commit ceeb818f41
144 files changed
+9018 -1407

No files matched your search

+36
View File
@@ -2,6 +2,42 @@
## [Unreleased]
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
### Added
- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation.
- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works.
### Changed
- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree.
- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question.
- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner.
### Fixed
- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due.
- The adherence bars keep a fixed geometry in every state instead of growing with their status text.
- The remaining hydration warnings on the dashboard, insights and AI settings are gone.
## [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
+17 -10
View File
@@ -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:<digest>` 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
+338 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.4
version: 1.16.6
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2398,6 +2398,162 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/schedule-revisions:
get:
tags:
- Medications
summary: List a medication's archived schedule eras
description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took
over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers
the Zeitplan-tab history timeline.
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Era list.
content:
application/json:
schema:
$ref: "#/components/schemas/ListScheduleRevisionsResponse"
"401": *a1
"404":
description: Medication not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
post:
tags:
- Medications
summary: Append a manual schedule era (pre-tracking history)
description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from
before the schedule was edited in the app. The era must end at or before the start of the live plan and must not
overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive
(`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently.
Audits as `medication.schedule_revision.created`.
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateScheduleRevisionRequest"
responses:
"201":
description: Manual era created.
content:
application/json:
schema:
$ref: "#/components/schemas/CreateScheduleRevisionResponse"
"401": *a1
"404":
description: Medication not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/schedule-revisions/{revisionId}:
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 — 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
schema:
type: string
required: true
- in: path
name: revisionId
schema:
type: string
required: true
responses:
"200":
description: Deletion succeeded.
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteScheduleRevisionResponse"
"401": *a1
"404":
description: Medication or revision not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/auth/me/coach-prefs:
get:
tags:
@@ -4791,6 +4947,35 @@ components:
description: Free-text medication description payload. The route runs the text through the Coach provider chain and
returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user;
budget-gated against the daily Coach token ceiling.
CreateScheduleRevisionRequest:
type: object
properties:
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: Inclusive start instant of the manual era (ISO 8601).
validUntil:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.
timesOfDay:
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)$
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:
@@ -9021,6 +9206,158 @@ components:
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
ListScheduleRevisionsResponse:
type: object
properties:
data:
type: object
properties:
currentSince:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when
no era has been archived."
revisions:
type: array
items:
$ref: "#/components/schemas/MedicationScheduleRevision"
description: Archived eras, newest first.
required:
- currentSince
- revisions
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationScheduleRevision:
type: object
properties:
id:
type: string
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
description: Inclusive start instant of the era.
validUntil:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
description: Exclusive end instant of the era — the moment the next plan took over.
source:
type: string
enum:
- ARCHIVED
- MANUAL
description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered
pre-tracking era (deletable).
entries:
type: array
items:
$ref: "#/components/schemas/ScheduleRevisionEntry"
required:
- id
- validFrom
- validUntil
- source
- entries
additionalProperties: false
description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies,
and cadence chips mint past days against the era that was live then.
ScheduleRevisionEntry:
type: object
properties:
timesOfDay:
type: array
items:
type: string
description: Daily dose times (HH:mm, user local) the era ran at.
label:
anyOf:
- type: string
- type: "null"
dose:
anyOf:
- type: string
- type: "null"
scheduleType:
type: string
description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC).
required:
- timesOfDay
- label
- dose
- scheduleType
additionalProperties: false
description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows,
…) stays server-side; this projection carries what the timeline renders.
CreateScheduleRevisionResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MedicationScheduleRevision"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
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:
data:
type: object
properties:
deleted:
type: boolean
required:
- deleted
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
GetCoachPrefsResponse:
type: object
properties:
+63 -10
View File
@@ -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:<digest>` — pin to the immutable digest.
2. Pin the compose `image:` (or the Coolify image reference) to
`ghcr.io/mbombeck/healthlog@sha256:<digest>` 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://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
-H "Authorization: Bearer <token>"
```
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
+252
View File
@@ -0,0 +1,252 @@
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.
*
* 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 });
const QUESTIONS = [
"Guided e2e question one?",
"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
// 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) => {
// 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) =>
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);
});
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");
});
});
+10 -4
View File
@@ -68,11 +68,17 @@
"docker",
"docker-compose",
"pg_isready",
"tsx"
"tsx",
"cosign"
],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
"src/components/ui/**": ["exports", "types"],
"src/lib/validations/**": ["types"]
"src/components/ui/**": [
"exports",
"types"
],
"src/lib/validations/**": [
"types"
]
}
}
}
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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",
@@ -3821,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",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,10 +5185,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 +5399,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?",
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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",
@@ -3821,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",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,10 +5185,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 +5399,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?",
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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í.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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",
@@ -3821,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",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,9 +5185,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 +5399,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?",
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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",
@@ -3821,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",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,9 +5185,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 +5399,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 ?",
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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ù",
@@ -3821,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",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,9 +5185,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 +5399,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?",
+101 -7
View File
@@ -1599,6 +1599,42 @@
"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.",
"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",
"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 +2031,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 +2039,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",
@@ -2082,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",
@@ -3461,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ół",
@@ -3821,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ę",
@@ -4231,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",
@@ -4252,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...",
@@ -4287,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",
@@ -4417,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",
@@ -4438,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",
@@ -4450,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.",
@@ -5109,9 +5185,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 +5399,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?",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.4",
"version": "1.16.6",
"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",
@@ -0,0 +1,19 @@
-- v1.16.5 — schedule-revision provenance.
--
-- The Zeitplan tab now lets a user append a PRE-tracking era by hand
-- ("the medication dosed at 07:00/19:00 from March to June, before I
-- edited it here"). Those rows are user-entered, not write-path
-- archives, so they must stay deletable without touching the archive
-- the schedule-replace path mints.
--
-- `source` records the provenance:
-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable
-- history; never deletable through the API).
-- 'MANUAL' — entered through POST
-- /api/medications/{id}/schedule-revisions (deletable by
-- the owner).
--
-- Additive + non-destructive: every existing row is a write-path
-- archive, so the default backfills correctly.
ALTER TABLE "medication_schedule_revisions"
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED';
@@ -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;
+13
View File
@@ -1470,6 +1470,19 @@ model MedicationScheduleRevision {
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
/// to rebuild a `CanonicalSchedule` list for era minting.
payload Json
/// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace
/// write path (immutable history). 'MANUAL' = user-entered pre-tracking
/// era via POST /api/medications/{id}/schedule-revisions (deletable by
/// the owner).
source String @default("ARCHIVED")
/// 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)
+242
View File
@@ -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<string> {
// 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<Response> {
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<void> {
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<void> {
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://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
-d '{"key":"${ENV_KEY}","value":"${value}"}'
# redeploy with the pinned digest
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
-H "Authorization: Bearer <token>"
`);
}
async function main(): Promise<void> {
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)));
+2
View File
@@ -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")}
>
<AssistantSection />
<AiServerKeySection />
</SectionFrame>
);
case "coach-feedback":
@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
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<string, unknown> }) => ({
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<string, unknown>;
};
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,
});
});
});
@@ -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" },
},
},
});
@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
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<Envelope>;
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();
});
});
+66 -40
View File
@@ -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),
+27 -3
View File
@@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
{ 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<Response> {
});
// ── 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<Response> {
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}
+6 -2
View File
@@ -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 },
});
@@ -0,0 +1,328 @@
/**
* v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId]
* v1.16.6 — PATCH /api/medications/[id]/schedule-revisions/[revisionId]
*
* 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,
* existence channel sealed like every medication sub-route.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
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();
const { id, revisionId } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const revision = await prisma.medicationScheduleRevision.findUnique({
where: { id: revisionId },
select: { id: true, medicationId: true, source: true },
});
if (!revision || revision.medicationId !== id) {
return apiError("Schedule revision not found", 404);
}
if (revision.source !== "MANUAL") {
return apiError(
"Only manually added schedule eras can be deleted",
409,
);
}
// 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,
ipAddress: getClientIp(request),
details: { medicationId: id, revisionId },
});
annotate({
action: {
name: "medication.schedule_revision.manual_deleted",
entity_type: "medication",
entity_id: id,
},
meta: { revision_id: revisionId },
});
// History re-segments without the era; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({ deleted: true });
},
);
@@ -0,0 +1,597 @@
/**
* v1.16.5 — schedule-era endpoints: ownership, validation, provenance.
*
* 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
* at or before the start of the live plan, must not overlap an
* archived era, and is always minted with `source: "MANUAL"`. The
* DELETE refuses write-path archives (409) so the immutable history
* the replace path mints can never be falsified through the API.
* 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", () => {
const prisma: Record<string, unknown> = {
medication: {
findUnique: vi.fn(),
},
medicationScheduleRevision: {
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", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
enqueueUserMedicationComplianceBackfill: vi
.fn()
.mockResolvedValue({ enqueued: true, error: null }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { DELETE, PATCH } from "../[revisionId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
function getReq(): NextRequest {
return new NextRequest(
"http://localhost/api/medications/m1/schedule-revisions",
);
}
function postReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/m1/schedule-revisions",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
function deleteReq(revisionId: string): NextRequest {
return new NextRequest(
`http://localhost/api/medications/m1/schedule-revisions/${revisionId}`,
{ method: "DELETE" },
);
}
function deleteCtx(revisionId: string) {
return { params: Promise.resolve({ id: "m1", revisionId }) };
}
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<Promise<unknown>>)) 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.
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
createdAt: MED_CREATED_AT,
} as never);
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/medications/[id]/schedule-revisions", () => {
it("404s for another user's medication (existence sealed)", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "someone-else",
createdAt: MED_CREATED_AT,
} as never);
const res = await GET(getReq(), ROUTE_CTX);
expect(res.status).toBe(404);
});
it("returns currentSince = createdAt when no revision exists", async () => {
const res = await GET(getReq(), ROUTE_CTX);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentSince).toBe(MED_CREATED_AT.toISOString());
expect(json.data.revisions).toEqual([]);
});
it("returns currentSince = newest validUntil and summarised entries", async () => {
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
{
id: "rev-2",
validFrom: new Date("2026-03-01T00:00:00Z"),
validUntil: new Date("2026-05-01T00:00:00Z"),
source: "ARCHIVED",
payload: [
{
timesOfDay: ["07:00", "19:00"],
label: "Morgens",
dose: null,
scheduleType: "SCHEDULED",
},
],
},
{
id: "rev-1",
validFrom: new Date("2026-01-10T08:00:00Z"),
validUntil: new Date("2026-03-01T00:00:00Z"),
source: "MANUAL",
// Malformed payload must degrade, never throw on a read path.
payload: { not: "an array" },
},
] as never);
const res = await GET(getReq(), ROUTE_CTX);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentSince).toBe("2026-05-01T00:00:00.000Z");
expect(json.data.revisions).toHaveLength(2);
expect(json.data.revisions[0].entries[0].timesOfDay).toEqual([
"07:00",
"19:00",
]);
expect(json.data.revisions[1].entries).toEqual([]);
expect(json.data.revisions[1].source).toBe("MANUAL");
});
});
describe("POST /api/medications/[id]/schedule-revisions", () => {
const VALID_BODY = {
validFrom: "2025-03-12T00:00:00.000Z",
validUntil: "2025-06-01T00:00:00.000Z",
timesOfDay: ["19:00", "07:00"],
};
it("404s for another user's medication", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "someone-else",
createdAt: MED_CREATED_AT,
} as never);
const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
expect(res.status).toBe(404);
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
});
it("422s when validFrom is not before validUntil", async () => {
const res = await POST(
postReq({ ...VALID_BODY, validUntil: VALID_BODY.validFrom }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
it("422s on a malformed time literal", async () => {
const res = await POST(
postReq({ ...VALID_BODY, timesOfDay: ["25:99"] }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
it("422s when the era overlaps an archived era", async () => {
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
{
validFrom: new Date("2025-05-01T00:00:00Z"),
validUntil: new Date("2026-01-01T00:00:00Z"),
},
] as never);
const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
expect(res.status).toBe(422);
const json = await res.json();
expect(json.error).toMatch(/overlap/i);
});
it("422s when the era ends after the live plan began", async () => {
// 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);
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(201);
});
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",
validFrom: new Date(VALID_BODY.validFrom),
validUntil: new Date(VALID_BODY.validUntil),
source: "MANUAL",
payload: [],
} as never);
const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
.mock.calls[0][0] as unknown as {
data: {
medicationId: string;
source: string;
payload: Array<{
timesOfDay: string[];
rrule: string | null;
windowStart: string;
windowEnd: string;
scheduleType: string;
}>;
};
};
expect(createArgs.data.medicationId).toBe("m1");
expect(createArgs.data.source).toBe("MANUAL");
expect(createArgs.data.payload).toHaveLength(1);
// Times sorted ascending; window pulled to their min/max; daily.
expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
expect(createArgs.data.payload[0].windowStart).toBe("07:00");
expect(createArgs.data.payload[0].windowEnd).toBe("19:00");
expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED");
// History re-segmented — the rollup backfill must be enqueued.
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
// 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"]);
});
});
describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
it("404s when the revision belongs to another medication", async () => {
vi.mocked(
prisma.medicationScheduleRevision.findUnique,
).mockResolvedValue({
id: "rev-x",
medicationId: "other-med",
source: "MANUAL",
} as never);
const res = await DELETE(deleteReq("rev-x"), deleteCtx("rev-x"));
expect(res.status).toBe(404);
expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
});
it("409s for a write-path archive", async () => {
vi.mocked(
prisma.medicationScheduleRevision.findUnique,
).mockResolvedValue({
id: "rev-a",
medicationId: "m1",
source: "ARCHIVED",
} as never);
const res = await DELETE(deleteReq("rev-a"), deleteCtx("rev-a"));
expect(res.status).toBe(409);
expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
});
it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => {
vi.mocked(
prisma.medicationScheduleRevision.findUnique,
).mockResolvedValue({
id: "rev-m",
medicationId: "m1",
source: "MANUAL",
} as never);
vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue(
{} as never,
);
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();
expect(json.data.deleted).toBe(true);
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<string, unknown> = {}) {
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",
);
});
});
@@ -0,0 +1,296 @@
/**
* v1.16.5 — schedule-era management for the Zeitplan tab.
*
* GET /api/medications/[id]/schedule-revisions
* - lists the medication's archived schedule eras (newest first)
* plus `currentSince`, the instant the LIVE plan took over (the
* newest revision's `validUntil`, or `medication.createdAt` when
* no era has been archived yet).
*
* POST /api/medications/[id]/schedule-revisions
* - appends a MANUAL era for pre-tracking history ("dosed at
* 07:00/19:00 from March to June"). Validation: the era must end
* at or before the start of the live era and must not overlap
* any existing revision. The snapshot payload mirrors what the
* write-path archive mints (`FREQ=DAILY`, window pulled to the
* min/max of the times) so the era minter — ledger, compliance,
* cadence chips — reads it like any other revision.
*
* Auth: cookie-session or Bearer via requireAuth(); the medication is
* verified to belong to the caller before any read or write. Deleting
* a manual era lives in the `[revisionId]` sub-route.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision";
import {
toRevisionPayloadEntry,
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
/**
* Display projection of one archived schedule row. Defensive parsing —
* a malformed payload entry degrades to an empty summary instead of
* throwing on a read path (mirrors `canonicalSchedulesFromRevision`).
*/
function summariseEntries(payload: unknown): Array<{
timesOfDay: string[];
label: string | null;
dose: string | null;
scheduleType: string;
}> {
if (!Array.isArray(payload)) return [];
return payload.map((raw) => {
const e = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
return {
timesOfDay: Array.isArray(e.timesOfDay)
? e.timesOfDay.filter((t): t is string => typeof t === "string")
: [],
label: typeof e.label === "string" ? e.label : null,
dose: typeof e.dose === "string" ? e.dose : null,
scheduleType: typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED",
};
});
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
select: { createdAt: true },
});
if (!med) {
return apiError("Medication not found", 404);
}
const revisions = await prisma.medicationScheduleRevision.findMany({
// 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,
validFrom: true,
validUntil: true,
source: true,
payload: true,
},
});
// The live plan has been current since the newest archived era
// ended; with no archive it has been current since creation.
const currentSince =
revisions.length > 0
? revisions.reduce(
(latest, r) => (r.validUntil > latest ? r.validUntil : latest),
revisions[0].validUntil,
)
: med.createdAt;
annotate({
action: {
name: "medication.schedule_revisions.list",
entity_type: "medication",
entity_id: id,
},
meta: { revision_count: revisions.length },
});
return apiSuccess({
currentSince: currentSince.toISOString(),
revisions: revisions.map((r) => ({
id: r.id,
validFrom: r.validFrom.toISOString(),
validUntil: r.validUntil.toISOString(),
source: r.source,
entries: summariseEntries(r.payload),
})),
});
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const 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 = scheduleRevisionCreateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const validFrom = new Date(parsed.data.validFrom);
const validUntil = new Date(parsed.data.validUntil);
// Sanity floor — a fat-fingered year ("0203") would otherwise mint
// an era the timeline renders as antiquity.
if (validFrom.getUTCFullYear() < 1900) {
return apiError("validFrom must be a date after 1900", 422);
}
// Snapshot entry shaped exactly like the write-path archive: daily
// recurrence at the given times, window pulled to their min/max.
// The schema already deduped + sorted the times.
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 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),
details: { medicationId: id, revisionId: revision.id },
});
annotate({
action: {
name: "medication.schedule_revision.manual_created",
entity_type: "medication",
entity_id: id,
},
meta: {
revision_id: revision.id,
era_days: Math.round(
(validUntil.getTime() - validFrom.getTime()) / 86_400_000,
),
},
});
// The new era re-segments history the compliance rollups already
// pre-aggregated; refresh them asynchronously. Best-effort like
// every other rollup write-hook.
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess(
{
id: revision.id,
validFrom: revision.validFrom.toISOString(),
validUntil: revision.validUntil.toISOString(),
source: revision.source,
entries: summariseEntries(revision.payload),
},
201,
);
},
);
+3 -1
View File
@@ -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 },
}),
]);
+16 -2
View File
@@ -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
+7 -1
View File
@@ -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;
+6 -2
View File
@@ -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,
@@ -21,6 +21,19 @@ 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 +44,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(
<I18nProvider initialLocale="en">
<AdminShell active={props.active}>
@@ -133,6 +149,22 @@ describe("<AdminShell>", () => {
).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.
@@ -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>): 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(
<I18nProvider initialLocale="de">
<QueryClientProvider client={client}>
<InviteTokensSection />
</QueryClientProvider>
</I18nProvider>,
);
}
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);
});
});
@@ -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",
);
});
});
@@ -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/,
);
});
});
+13
View File
@@ -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 (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
);
}
export function StatusItem({
icon: Icon,
label,
+12 -3
View File
@@ -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
@@ -252,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",
+23 -26
View File
@@ -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 (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
@@ -90,14 +91,12 @@ export function AiQualitySection() {
if (!summary || summary.buckets.length === 0) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground mt-3 text-sm">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
/>
<p className="text-muted-foreground mt-3 pl-7 text-sm">
{t("admin.aiQuality.empty")}
</p>
</div>
@@ -105,23 +104,21 @@ export function AiQualitySection() {
}
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
</div>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
status={
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
@@ -0,0 +1,188 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { PasswordInput } from "./_shared";
/**
* v1.16.6 — operator panel for the server-wide AI key.
*
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
* existed since the `admin-openai` chain fallback shipped, but it never had
* an operator UI — configuring the shared key took a manual API call. This
* card is its home, beside the assistant feature flags.
*
* Semantics worth keeping visible to the operator:
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
* key sits at the END of the provider chain as the shared fallback.
* - Egress over the server key is consent-gated per user
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
* key here never silently forwards anyone's health data.
* - The base URL is locked server-side to HTTPS + a hostname allowlist
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
*/
interface AiServerKeyResponse {
hasKey: boolean;
keyPreview: string | null;
model: string;
baseUrl: string;
}
export function AiServerKeySection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useQuery({
queryKey: queryKeys.adminAiServerKey(),
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
});
const [keyDraft, setKeyDraft] = useState("");
const [modelDraft, setModelDraft] = useState<string | null>(null);
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
const modelValue = modelDraft ?? settings?.model ?? "";
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
const configured = settings?.hasKey ?? false;
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
onSuccess: () => {
setKeyDraft("");
setModelDraft(null);
setBaseUrlDraft(null);
queryClient.invalidateQueries({
queryKey: queryKeys.adminAiServerKey(),
});
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
);
},
});
function handleSave() {
const payload: Record<string, unknown> = {
model: modelValue,
baseUrl: baseUrlValue,
};
if (keyDraft.trim().length > 0) {
payload.apiKey = keyDraft.trim();
}
save.mutate(payload);
}
function handleRemove() {
save.mutate({ apiKey: "" });
}
return (
<div
data-slot="admin-ai-server-key"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
description={
<>
<p>{t("admin.aiServerKey.description")}</p>
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</>
}
status={
<Badge variant={configured ? "default" : "outline"}>
{configured
? t("admin.aiServerKey.statusConfigured")
: t("admin.aiServerKey.statusNotConfigured")}
</Badge>
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
</Label>
<PasswordInput
id="admin-ai-server-key-input"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder={
configured && settings?.keyPreview
? t("admin.aiServerKey.keyPlaceholderSet", {
preview: settings.keyPreview,
})
: t("admin.aiServerKey.keyPlaceholder")
}
autoComplete="off"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-model">
{t("admin.aiServerKey.modelLabel")}
</Label>
<Input
id="admin-ai-server-key-model"
value={modelValue}
onChange={(e) => setModelDraft(e.target.value)}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-base-url">
{t("admin.aiServerKey.baseUrlLabel")}
</Label>
<Input
id="admin-ai-server-key-base-url"
value={baseUrlValue}
onChange={(e) => setBaseUrlDraft(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleSave}
disabled={save.isPending}
data-slot="admin-ai-server-key-save"
>
{save.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.aiServerKey.save")}
</Button>
{configured && (
<Button
variant="outline"
onClick={handleRemove}
disabled={save.isPending}
data-slot="admin-ai-server-key-remove"
>
{t("admin.aiServerKey.remove")}
</Button>
)}
</div>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate, formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type ApiTokenInfo } from "./_shared";
@@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() {
return (
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Key className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
</div>
<SettingsCardHeader
icon={Key}
title={t("admin.apiTokens")}
description={t("admin.apiTokensDescription")}
/>
<div className="mt-4">
{isLoading ? (
@@ -46,6 +46,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { WideEvent, LogLevel } from "@/lib/logging/types";
@@ -104,7 +105,9 @@ export function AppLogPreviewSection() {
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
queryFn: async () => {
return apiGet<AppLogsResponse>(`/api/admin/app-logs?${params.toString()}`);
return apiGet<AppLogsResponse>(
`/api/admin/app-logs?${params.toString()}`,
);
},
refetchInterval: 30_000,
});
@@ -112,31 +115,25 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<header className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">
{t("admin.section.app-logs.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.app-logs.processNote")}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
description={t("admin.section.app-logs.processNote")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
}
/>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
+73 -71
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
@@ -49,7 +50,9 @@ function useAssistantFlags() {
return useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: async () => {
return apiGet<AssistantFlagsResponse>("/api/admin/settings/assistant-flags");
return apiGet<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
);
},
});
}
@@ -61,13 +64,13 @@ function useUpdateAssistantFlags() {
mutationFn: async (
patch: Partial<AssistantFlagsResponse["raw"]>,
): Promise<AssistantFlagsResponse> => {
return apiPut<AssistantFlagsResponse>("/api/admin/settings/assistant-flags", patch);
return apiPut<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
patch,
);
},
onSuccess: (data) => {
client.setQueryData(
queryKeys.adminAssistantFlags(),
data,
);
client.setQueryData(queryKeys.adminAssistantFlags(), data);
// Bust the runtime `/api/feature-flags` cache so the operator
// sees the toggled surface react within the same session.
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
@@ -96,73 +99,72 @@ export function AssistantSection() {
const disabledSubs = !masterOn || mutation.isPending;
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
</div>
<p className="text-muted-foreground pl-7 text-sm">
{t("admin.assistant.description")}
</p>
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
checked={raw?.assistantEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantEnabled: checked })
}
disabled={mutation.isPending}
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.assistant.title")}
description={t("admin.assistant.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
checked={raw?.assistantEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantEnabled: checked })
}
disabled={mutation.isPending}
/>
<div className="border-border space-y-4 border-t pt-4">
<SettingsToggle
label={t("admin.assistant.coach.title")}
description={t("admin.assistant.coach.description")}
checked={raw?.assistantCoachEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCoachEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.briefing.title")}
description={t("admin.assistant.briefing.description")}
checked={raw?.assistantBriefingEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantBriefingEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.insightStatus.title")}
description={t("admin.assistant.insightStatus.description")}
checked={raw?.assistantInsightStatusEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantInsightStatusEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.healthScoreExplainer.title")}
description={t("admin.assistant.healthScoreExplainer.description")}
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({
assistantHealthScoreExplainerEnabled: checked,
})
}
disabled={disabledSubs}
/>
<div className="border-border space-y-4 border-t pt-4">
<SettingsToggle
label={t("admin.assistant.coach.title")}
description={t("admin.assistant.coach.description")}
checked={raw?.assistantCoachEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCoachEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.briefing.title")}
description={t("admin.assistant.briefing.description")}
checked={raw?.assistantBriefingEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantBriefingEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.insightStatus.title")}
description={t("admin.assistant.insightStatus.description")}
checked={raw?.assistantInsightStatusEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantInsightStatusEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.healthScoreExplainer.title")}
description={t("admin.assistant.healthScoreExplainer.description")}
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({
assistantHealthScoreExplainerEnabled: checked,
})
}
disabled={disabledSubs}
/>
</div>
</div>
</div>
);
+26 -25
View File
@@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
@@ -331,34 +332,34 @@ export function BackupsSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Database className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.section.backups.title")}
</div>
{data && (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Database}
title={t("admin.section.backups.title")}
titleAccessory={
data ? (
<Badge variant="secondary" className="text-xs">
{rows.length}
</Badge>
)}
</div>
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
) : null
}
status={
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
}
/>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.section.backups.description")}{" "}
{/* External docs link — Phase E will publish the matching page on
the docs site. `noopener noreferrer` because this leaves the
+15 -22
View File
@@ -2,13 +2,18 @@
import { useState } from "react";
import { Bug, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function BugReportSection() {
const { t } = useTranslations();
@@ -45,27 +50,15 @@ export function BugReportSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bug className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.bugReportGithub")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.bugReportGithubDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bug}
title={t("admin.bugReportGithub")}
description={t("admin.bugReportGithubDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
<div>
<p className="text-sm font-medium">
{t("admin.bugReportEnabledLabel")}
+18 -19
View File
@@ -21,6 +21,7 @@
import { useQuery } from "@tanstack/react-query";
import { Loader2, Sparkles } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -69,25 +70,23 @@ export function CoachFeedbackSection() {
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.coachFeedback.title")}
</div>
</div>
{hasData && summary && (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
)}
</div>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
status={
hasData && summary ? (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
) : null
}
/>
{query.isLoading ? (
<div className="flex items-center gap-2">
+1 -1
View File
@@ -59,7 +59,7 @@ export function DangerZoneSection() {
});
return (
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-6">
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
already names the section, so the card-level title was a
duplicate of the page title. The destructive icon stays so
@@ -54,7 +54,9 @@ export function FeedbackInboxSection() {
const { data, isLoading } = useQuery({
queryKey: queryKeys.adminFeedback(activeStatus),
queryFn: async () => {
return apiGet<FeedbackListResponse>(`/api/admin/feedback?status=${activeStatus}&limit=100`);
return apiGet<FeedbackListResponse>(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
},
});
@@ -65,7 +67,7 @@ export function FeedbackInboxSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
@@ -287,7 +289,9 @@ function FeedbackDetailDialog({
const publish = useMutation({
mutationFn: async () => {
return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`);
return apiPost<{ issueUrl: string }>(
`/api/admin/feedback/${item.id}/github`,
);
},
onSuccess: (data) => {
setIssueUrl(data.issueUrl);
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import { Settings } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -25,12 +26,13 @@ export function GeneralSettingsSection() {
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Settings className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
</div>
<div className="mt-4 space-y-4">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Settings}
title={t("admin.appSettings")}
description={t("admin.appSettingsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
+10 -19
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,6 +13,7 @@ import {
SettingsToggle,
useAdminSettings,
useUpdateSettings,
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -68,25 +69,15 @@ export function GlitchtipSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.glitchtipDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.glitchtipTitle")}
description={t("admin.glitchtipDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label={t("admin.glitchtipEnabled")}
icon={AlertTriangle}
+96 -75
View File
@@ -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();
@@ -307,12 +314,15 @@ export function InviteTokensSection() {
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Ticket
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-sm">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
@@ -552,73 +562,79 @@ export function InviteTokensSection() {
}}
className="space-y-4"
>
<div className="space-y-5">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
type="button"
role="radio"
aria-checked={ttlDays === days}
onClick={() => setTtlDays(days)}
data-testid={`admin-invites-ttl-${days}`}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
: "border-border text-foreground hover:bg-accent",
)}
>
{t("admin.invites.createDays", { days })}
</button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="admin-invites-max-uses">
{t("admin.invites.maxUsesLabel")}
</Label>
<Input
id="admin-invites-max-uses"
type="number"
inputMode="numeric"
min={1}
max={MAX_USES_CAP}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
className="w-24 tabular-nums"
data-testid="admin-invites-max-uses"
/>
<p className="text-muted-foreground text-xs">
{t("admin.invites.maxUsesHint")}
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-testid="admin-invites-submit-create"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
<div className="space-y-5">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
type="button"
role="radio"
aria-checked={ttlDays === days}
onClick={() => setTtlDays(days)}
data-testid={`admin-invites-ttl-${days}`}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
: "border-border text-foreground hover:bg-accent",
)}
>
{t("admin.invites.createDays", { days })}
</button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="admin-invites-max-uses">
{t("admin.invites.maxUsesLabel")}
</Label>
<Input
id="admin-invites-max-uses"
type="number"
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"
/>
) : (
<Ticket className="size-4" aria-hidden="true" />
)}
{t("admin.invites.createConfirm")}
</Button>
</DialogFooter>
<p className="text-muted-foreground text-xs">
{t("admin.invites.maxUsesHint")}
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-testid="admin-invites-submit-create"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Ticket className="size-4" aria-hidden="true" />
)}
{t("admin.invites.createConfirm")}
</Button>
</DialogFooter>
</form>
</>
) : (
@@ -753,7 +769,12 @@ function RedemptionsCell({
count: invite.redemptions.length,
})}
</p>
<ul className="mt-2 space-y-2">
{/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
list height so the popover never outgrows the viewport. */}
<ul
className="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
data-testid="admin-invite-redemptions-list"
>
{invite.redemptions.map((entry) => (
<li key={entry.id} className="text-sm leading-tight">
<div>
@@ -21,6 +21,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toCSV } from "@/lib/export";
@@ -131,7 +132,9 @@ export function LoginOverviewSection() {
if (filter === "failed") {
params.set("action", "auth.login.failed");
}
return apiGet<AuditLogResponse>(`/api/admin/audit-log?${params.toString()}`);
return apiGet<AuditLogResponse>(
`/api/admin/audit-log?${params.toString()}`,
);
},
});
@@ -193,11 +196,12 @@ export function LoginOverviewSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<ScrollText className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
</div>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={ScrollText}
title={t("admin.loginOverview")}
description={t("admin.loginOverviewDescription")}
/>
<div className="mt-4 space-y-3">
{/* Quick-filter pills. Failed-only is the most common
+18 -19
View File
@@ -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<AuditLogResponse>(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`);
return apiGet<AuditLogResponse>(
`/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 (
<section
aria-labelledby="admin-overview-audit-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-audit-heading"
className="text-lg font-semibold"
<SettingsCardHeader
icon={ScrollText}
titleId="admin-overview-audit-heading"
title={t("admin.overview.auditTitle")}
status={
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
>
{t("admin.overview.auditTitle")}
</h2>
</div>
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
}
/>
<div className="mt-4">
{isLoading ? (
+11 -14
View File
@@ -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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
{/* 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. */}
<Bell className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.medicationReminders")}
</div>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* 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. */}
<SettingsCardHeader
icon={Bell}
title={t("admin.medicationReminders")}
description={t("admin.medicationRemindersDescription")}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
+8 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -10,15 +11,13 @@ export function ServicesSection() {
const updateSettings = useUpdateSettings();
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Globe className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.servicesGlobalDescription")}
</p>
<div className="mt-4 space-y-3">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Globe}
title={t("admin.servicesGlobal")}
description={t("admin.servicesGlobalDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label="Telegram"
description={t("admin.telegramGlobal")}
@@ -21,6 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
@@ -51,13 +52,14 @@ export function SystemStatusSection() {
return (
<div className="space-y-6">
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Server className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
</div>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Server}
title={t("admin.systemStatus")}
description={t("admin.systemStatusDescription")}
/>
{status ? (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
<StatusItem
icon={Database}
label={t("admin.database")}
+8 -11
View File
@@ -23,6 +23,7 @@ import {
Tag,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
@@ -34,20 +35,16 @@ export function SystemStatusSummary() {
return (
<section
aria-labelledby="admin-overview-snapshot-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-snapshot-heading"
className="text-lg font-semibold"
>
{t("admin.overview.snapshotTitle")}
</h2>
</div>
<SettingsCardHeader
icon={Server}
titleId="admin-overview-snapshot-heading"
title={t("admin.overview.snapshotTitle")}
/>
{status ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
+10 -19
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Activity, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,6 +13,7 @@ import {
SettingsToggle,
useAdminSettings,
useUpdateSettings,
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -71,25 +72,15 @@ export function UmamiSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.umamiDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Activity}
title={t("admin.umamiTitle")}
description={t("admin.umamiDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label={t("admin.umamiEnabled")}
icon={Activity}
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
@@ -220,18 +221,12 @@ export function UserManagementSection() {
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* Header: stacks vertically on mobile so the filter pills get
their own row instead of overlapping the title. The audit
flagged this as CRITICAL #2 — at 393px the filter row was
colliding with the count Badge and the user-count digit was
floating into the title's gap. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<div className="flex items-center gap-2">
<Users className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.userManagement")}
</div>
{filteredUsers && (
<SettingsCardHeader
icon={Users}
title={t("admin.userManagement")}
description={t("admin.userManagementDescription")}
titleAccessory={
filteredUsers ? (
<Badge variant="secondary" className="text-xs">
{filteredUsers.length}
{filter !== "all" &&
@@ -242,24 +237,25 @@ export function UserManagementSection() {
</span>
)}
</Badge>
)}
</div>
{/* Filters mirror the Settings-style horizontal pills */}
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
</div>
) : null
}
status={
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
}
/>
{filteredUsers ? (
filteredUsers.length === 0 ? (
+39 -35
View File
@@ -16,9 +16,16 @@
* (`/admin`), which gates on `requireAdmin()`.
*/
import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
import {
ArrowUpCircle,
CheckCircle2,
Clock,
GitCommit,
Tag,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
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 { ApiError, apiGet } from "@/lib/api/api-fetch";
@@ -77,42 +84,39 @@ export function VersionTileSection() {
return (
<section
aria-labelledby="admin-overview-version-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-wrap items-center gap-2">
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-version-heading"
className="text-lg font-semibold"
>
{t("admin.overview.versionTileTitle")}
</h2>
<SettingsCardHeader
icon={Tag}
titleId="admin-overview-version-heading"
title={t("admin.overview.versionTileTitle")}
status={
newer ? (
<a
href={
(update.status === "newer_available" && update.html_url) ||
"https://github.com/MBombeck/HealthLog/releases/latest"
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
tag:
update.status === "newer_available" ? update.latest_tag : "",
})}
</a>
) : upToDate ? (
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : null
}
/>
{newer ? (
<a
href={
(update.status === "newer_available" && update.html_url) ||
"https://github.com/MBombeck/HealthLog/releases/latest"
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
tag:
update.status === "newer_available" ? update.latest_tag : "",
})}
</a>
) : upToDate ? (
<span className="text-dracula-green ml-auto inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : null}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.versionTileVersion")}
+15 -22
View File
@@ -2,12 +2,17 @@
import { useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function WebPushVapidSection() {
const { t } = useTranslations();
@@ -48,27 +53,15 @@ export function WebPushVapidSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.webPushVapidTitle")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.webPushVapidDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={BellRing}
title={t("admin.webPushVapidTitle")}
description={t("admin.webPushVapidDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-public-key" className="text-xs">
{t("admin.webPushVapidPublicKey")}
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const noop = () => {};
describe("<GuidedQuestionsCard>", () => {
it("renders the plural offer with the count and all three choices", () => {
const html = render(
<GuidedQuestionsCard
count={3}
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
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(
<GuidedQuestionsCard
count={1}
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
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(
<GuidedQuestionsCard
count={2}
disabled
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
expect(html.match(/disabled=""/g)?.length).toBe(3);
});
});
describe("<GuidedQuestionBubble>", () => {
it("renders progress + question text for the current question with actions", () => {
const html = render(
<GuidedQuestionBubble
question="Do you have any chronic conditions?"
progress={{ current: 1, total: 3 }}
current
onSkip={noop}
onLater={noop}
onDismissRemaining={noop}
/>,
);
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(
<GuidedQuestionBubble
question="Do you have any chronic conditions?"
progress={{ current: 1, total: 3 }}
/>,
);
expect(html).toContain('data-state="answered"');
expect(html).not.toContain('data-slot="coach-guided-actions"');
});
it("localises the progress label", () => {
const html = render(
<GuidedQuestionBubble
question="Hast du chronische Erkrankungen?"
progress={{ current: 2, total: 3 }}
/>,
"de",
);
expect(html).toContain("Frage 2 von 3");
});
});
describe("<GuidedSummaryBubble>", () => {
it("recaps answered + adopted counts and links to the self-context", () => {
const html = render(
<GuidedSummaryBubble answered={2} adopted={1} total={3} />,
);
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(
<GuidedSummaryBubble answered={1} adopted={0} total={3} />,
);
expect(html).toContain("nothing new added to your self-context");
});
});
@@ -0,0 +1,326 @@
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("guidedReducer — reaction-then-adopt sequencing (v1.16.6)", () => {
// With a provider, the turn streams a Coach reaction and the flow
// waits in `answered` until the adopt offer settles; the settle of
// the CURRENT outcome is what advances the sequence.
it("ADOPTION_SETTLED for the current outcome advances to the next question", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.index).toBe(1);
expect(state.outcomes[0].adoption).toBe("adopted");
});
it("ADOPTION_SETTLED on the last answer lands on the summary", () => {
const state = run([
{ type: "START", questions: ["only one"] },
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "declined" },
]);
expect(state.phase).toBe("summary");
if (state.phase !== "summary") throw new Error("unreachable");
expect(state.outcomes[0].adoption).toBe("declined");
});
it("a late settle for an older outcome does not advance", () => {
// Silent (provider-less) flow already advanced via TURN_COMPLETE;
// question 2 is answered and streaming when outcome 0 settles.
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
{ type: "ANSWER_SUBMITTED", answer: "Sleep." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "answered") throw new Error("unreachable");
expect(state.index).toBe(1);
expect(state.outcomes[0].adoption).toBe("adopted");
});
it("the silent flow still advances on TURN_COMPLETE alone", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.index).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 },
});
});
});
@@ -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: <span data-testid={key} />,
});
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("<MessageThread> interleaved guided bubbles", () => {
it("renders an unanchored item at the tail even on an empty thread", () => {
const html = render(
<MessageThread
conversation={null}
interleaved={[
{
key: "q1",
anchorAnswer: null,
node: <div data-slot="guided-test-bubble">First question?</div>,
},
]}
/>,
);
// 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(
<MessageThread
conversation={baseConversation}
interleaved={[
{
key: "q1",
anchorAnswer: "Why was BP higher on Monday?",
node: <div data-slot="guided-test-bubble">Guided question?</div>,
},
]}
/>,
);
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);
});
});
@@ -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<string | null>(
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,105 @@ 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({
// v1.16.6 — hand the question to the turn so the Coach reaction is
// contextual (the question bubble itself is never persisted).
const resolvedId = await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
guidedQuestion: guidedQuestion ?? undefined,
});
if (chipQuestion && answer) {
setPendingAdopt({ question: chipQuestion, answer });
if (guidedQuestion !== null && guidedIndex !== null) {
setPendingAdopt({
question: guidedQuestion,
answer: trimmed,
guidedIndex,
});
// v1.16.6 — sequence per answer: answer → Coach reaction
// (streamed above) → adopt offer → next question. A resolved id
// means the reaction landed; the machine then advances when the
// adopt offer settles (ADOPTION_SETTLED). A null id is the
// provider-less / errored turn — no reaction is coming, so keep
// the original silent flow and advance immediately.
if (resolvedId === null) {
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" ? (
<GuidedSummaryBubble
answered={item.summary?.answered ?? 0}
adopted={item.summary?.adopted ?? 0}
total={item.summary?.total ?? 0}
/>
) : (
<GuidedQuestionBubble
question={item.question ?? ""}
progress={item.progress ?? { current: 1, total: 1 }}
current={item.current}
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
onSkip={
item.current
? () => dispatchGuided({ type: "SKIP" })
: undefined
}
onLater={
item.current
? () => dispatchGuided({ type: "EXIT" })
: undefined
}
onDismissRemaining={
item.current
? () =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "EXIT" }),
})
: undefined
}
/>
),
}),
);
return (
<div
data-slot="coach-conversation"
@@ -250,7 +352,13 @@ export function CoachConversation({
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
) : undefined
}
@@ -259,30 +367,47 @@ export function CoachConversation({
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* 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 ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => 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 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({ type: "START", questions: pendingQuestions })
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
<SelfContextChips
disabled={send.isStreaming}
onPick={(question) => {
setActiveChipQuestion(question);
setInputValue(`${question}\n`);
}}
/>
<CoachInput
value={inputValue}
onChange={setInputValue}
@@ -291,6 +416,11 @@ export function CoachConversation({
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
</div>
}
@@ -311,6 +441,8 @@ export function CoachConversation({
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
}
@@ -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<HTMLTextAreaElement | null>(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
@@ -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 (
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Icon className="text-background size-3.5" />
</div>
);
}
/**
* 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 (
<span aria-hidden="true" className="inline-flex items-center gap-1">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
))}
</span>
);
}
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<boolean>(() => {
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 (
<div
data-slot="coach-guided-question"
data-state={current ? "current" : "answered"}
className="flex items-start gap-2.5"
>
<GuidedAvatar icon={MessageCircleQuestion} />
<div className="flex max-w-[calc(80%-2.625rem)] min-w-0 flex-col gap-1.5">
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{revealed ? (
<>
<p
data-slot="coach-guided-progress"
className="text-dracula-purple mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
>
{t("insights.coach.guided.progress", {
current: progress.current,
total: progress.total,
})}
<ProgressDots
current={progress.current}
total={progress.total}
/>
</p>
<p className="whitespace-pre-wrap">{question}</p>
</>
) : (
<TypingDots label={t("insights.coach.thinking")} />
)}
</div>
{current && revealed && (
<div
data-slot="coach-guided-actions"
className="flex flex-wrap items-center gap-1"
>
<button
type="button"
data-slot="coach-guided-skip"
disabled={actionsDisabled}
onClick={onSkip}
className={guidedActionClass}
>
{t("insights.coach.guided.skip")}
</button>
<button
type="button"
data-slot="coach-guided-later"
disabled={actionsDisabled}
onClick={onLater}
className={guidedActionClass}
>
{t("insights.coach.guided.later")}
</button>
<button
type="button"
data-slot="coach-guided-dismiss"
disabled={actionsDisabled}
onClick={onDismissRemaining}
className={guidedActionClass}
>
{t("insights.coach.guided.dismissRemaining")}
</button>
</div>
)}
</div>
</div>
);
}
export interface GuidedSummaryBubbleProps {
answered: number;
adopted: number;
total: number;
}
export function GuidedSummaryBubble({
answered,
adopted,
total,
}: GuidedSummaryBubbleProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-summary"
className="flex items-start gap-2.5"
>
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"max-w-[calc(80%-2.625rem)] min-w-0 rounded-xl rounded-tl-sm border px-3.5 py-2.5",
)}
>
<p className="text-sm font-medium">
{t("insights.coach.guided.summaryTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("insights.coach.guided.summaryAnswered", { answered, total })}
{" · "}
{adopted > 0
? t("insights.coach.guided.summaryAdopted", { adopted })
: t("insights.coach.guided.summaryAdoptedNone")}
</p>
<Link
href="/settings/ai"
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"hover:underline focus-visible:ring-dracula-purple/40 rounded focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
<ArrowRight className="size-3" aria-hidden="true" />
</Link>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
"use client";
/**
* v1.16.5 — entry card for the guided clarifying-questions flow.
*
* Replaces the v1.16.0 loose composer chips: when pending self-context
* questions exist, this quiet card sits above the composer and offers
* the guided sequence instead of scattering tappable chips. Three
* choices, mirroring common scripted-onboarding chat patterns:
*
* - "Answer now" starts the in-chat sequence (machine START).
* - "Later" hides the offer for this session; the questions stay
* pending and the card returns on the next mount.
* - "Don't ask again" dismisses every pending question server-side
* (existing DELETE with empty body).
*
* Renders nothing when no questions pend — zero vertical cost.
*/
import { MessageCircleQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
export interface GuidedQuestionsCardProps {
/** Pending-question count (1–3); the parent hides the card at 0. */
count: number;
onStart: () => void;
onLater: () => void;
onDismissAll: () => void;
/** Disabled while a reply streams or the dismiss request is in flight. */
disabled?: boolean;
}
export function GuidedQuestionsCard({
count,
onStart,
onLater,
onDismissAll,
disabled = false,
}: GuidedQuestionsCardProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-offer"
className={cn(
"border-dracula-purple/30 from-dracula-purple/10 to-dracula-pink/5",
"mb-2 rounded-lg border bg-gradient-to-r px-3 py-2.5",
)}
>
<div className="flex items-start gap-2.5">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<MessageCircleQuestion className="text-background size-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{count === 1
? t("insights.coach.guided.offerTitleOne")
: t("insights.coach.guided.offerTitle", { count })}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("insights.coach.guided.offerBody")}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<button
type="button"
data-slot="coach-guided-offer-start"
disabled={disabled}
onClick={onStart}
className={cn(
"bg-dracula-purple/15 text-dracula-purple inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-dracula-purple/25 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{t("insights.coach.guided.offerStart")}
</button>
<button
type="button"
data-slot="coach-guided-offer-later"
disabled={disabled}
onClick={onLater}
className={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",
)}
>
{t("insights.coach.guided.offerLater")}
</button>
<button
type="button"
data-slot="coach-guided-offer-dismiss"
disabled={disabled}
onClick={onDismissAll}
className={cn(
"text-muted-foreground/70 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",
)}
>
{t("insights.coach.guided.offerDismiss")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,273 @@
/**
* v1.16.5 — guided clarifying-questions flow, the V2 of the v1.16.0
* composer chips.
*
* After the user saves Settings → AI → "About me", up to 3 pending
* clarifying questions live encrypted on the profile
* (`/api/coach/about-me/questions`). V1 surfaced them as loose chips
* the user had to pick apart; V2 walks them one at a time inside the
* chat: an entry card offers the sequence, the Coach asks each
* question as a deterministic bubble (no model call), the user answers
* in the normal composer, the existing `/adopt` offer folds the answer
* into the matching self-context field, and a closing summary says
* what was taken over.
*
* This module is the whole client-side state machine, kept pure so the
* transitions can be unit-tested without a DOM:
*
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
* │ │ ▲ │
* LATER SKIP└── TURN_COMPLETE ──────────┤
* │ │ (provider-less: advance │
* │ │ right after the send) │
* │ │ ▲ │
* │ └── ADOPTION_SETTLED ─────────┘
* │ │ (current outcome: Coach reaction has
* │ │ streamed, the adopt offer settled —
* ▼ │ NOW the next question may appear)
* done ◀────────────┴──EXIT/SKIP-on-last──▶ summary (answers exist)
* ──▶ done (none answered)
*
* Server contract stays additive-free: answering dismisses that one
* question (existing DELETE), "skip" / "later" leave questions pending
* for the next session, "don't ask again" dismisses all (existing
* DELETE with empty body). No new schema, no new endpoint.
*/
/** Lifecycle of one answered question's adopt offer. */
export type GuidedAdoption =
| "pending"
| "adopted"
| "duplicate"
| "declined"
| "failed";
export interface GuidedOutcome {
/** Index of the question in the session snapshot (progress label). */
index: number;
question: string;
/** The composer message that answered it (thread anchor). */
answer: string;
adoption: GuidedAdoption;
}
export type GuidedState =
| { phase: "idle" }
| { phase: "done" }
| {
/**
* `asking` — the current question bubble is live and the next
* composer submit answers it. `answered` — the answer was sent
* and the assistant turn is still streaming; no current bubble.
*/
phase: "asking" | "answered";
/** Snapshot taken at START; pending-question edits mid-flow don't reshuffle the sequence. */
questions: string[];
index: number;
outcomes: GuidedOutcome[];
}
| { phase: "summary"; questions: string[]; outcomes: GuidedOutcome[] };
export const GUIDED_IDLE: GuidedState = { phase: "idle" };
export type GuidedEvent =
| { type: "START"; questions: string[] }
/** Entry-card "later": hide the offer for this session, keep questions pending. */
| { type: "LATER" }
/** Composer submit while `asking` — the message answers the current question. */
| { type: "ANSWER_SUBMITTED"; answer: string }
/** The assistant turn for the answer finished; advance the sequence. */
| { type: "TURN_COMPLETE" }
/** Skip the current question (stays pending server-side). */
| { type: "SKIP" }
/** Leave the flow ("later" / "don't ask again" mid-sequence). */
| { type: "EXIT" }
/** The adopt offer for an answered question settled. */
| {
type: "ADOPTION_SETTLED";
index: number;
adoption: Exclude<GuidedAdoption, "pending">;
}
/** New chat / conversation switch / drawer close — drop the session. */
| { type: "RESET" };
/**
* Close the sequence: answered questions earn the summary bubble;
* a flow abandoned before any answer just ends (the entry card will
* re-offer the still-pending questions next session).
*/
function finish(state: {
questions: string[];
outcomes: GuidedOutcome[];
}): GuidedState {
return state.outcomes.length > 0
? { phase: "summary", questions: state.questions, outcomes: state.outcomes }
: { phase: "done" };
}
export function guidedReducer(
state: GuidedState,
event: GuidedEvent,
): GuidedState {
switch (event.type) {
case "RESET":
return GUIDED_IDLE;
case "START": {
if (state.phase !== "idle" && state.phase !== "done") return state;
if (event.questions.length === 0) return state;
return {
phase: "asking",
questions: [...event.questions],
index: 0,
outcomes: [],
};
}
case "LATER":
return state.phase === "idle" ? { phase: "done" } : state;
case "ANSWER_SUBMITTED": {
if (state.phase !== "asking") return state;
return {
...state,
phase: "answered",
outcomes: [
...state.outcomes,
{
index: state.index,
question: state.questions[state.index],
answer: event.answer,
adoption: "pending",
},
],
};
}
case "TURN_COMPLETE": {
if (state.phase !== "answered") return state;
return advance(state);
}
case "SKIP": {
if (state.phase !== "asking") return state;
const next = state.index + 1;
if (next < state.questions.length) {
return { ...state, index: next };
}
return finish(state);
}
case "EXIT": {
if (state.phase !== "asking") return state;
return finish(state);
}
case "ADOPTION_SETTLED": {
if (
state.phase !== "asking" &&
state.phase !== "answered" &&
state.phase !== "summary"
) {
return state;
}
const outcomes = state.outcomes.map((o) =>
o.index === event.index && o.adoption === "pending"
? { ...o, adoption: event.adoption }
: o,
);
// v1.16.6 — when a provider reacted to the answer the sequence
// waits in `answered` until the adopt offer settles, so the
// transcript reads answer → Coach reaction → adopt outcome →
// next question. Settling the CURRENT outcome is that signal;
// a late settle for an older outcome (provider-less flow, where
// TURN_COMPLETE already advanced) only updates the tally.
if (state.phase === "answered" && event.index === state.index) {
return advance({ ...state, outcomes });
}
return { ...state, outcomes };
}
}
}
/** Move past the current question: next one, or close the sequence. */
function advance(state: {
questions: string[];
index: number;
outcomes: GuidedOutcome[];
}): GuidedState {
const next = state.index + 1;
if (next < state.questions.length) {
return {
phase: "asking",
questions: state.questions,
index: next,
outcomes: state.outcomes,
};
}
return finish(state);
}
/** Count of answers actually written into the self-context. */
export function countAdopted(outcomes: GuidedOutcome[]): number {
return outcomes.filter((o) => o.adoption === "adopted").length;
}
/**
* One in-thread bubble the guided flow contributes. Answered questions
* anchor immediately BEFORE the user message that answered them
* (`anchorAnswer` = that message's content) so the transcript reads
* question → answer → reply; the current question and the summary
* carry `anchorAnswer: null` and render at the thread tail.
*/
export interface GuidedThreadItem {
key: string;
kind: "question" | "summary";
/** kind=question */
question?: string;
progress?: { current: number; total: number };
/** kind=question: live question — render actions + typing reveal. */
current?: boolean;
/** kind=summary */
summary?: { answered: number; adopted: number; total: number };
anchorAnswer: string | null;
}
export function deriveThreadItems(state: GuidedState): GuidedThreadItem[] {
if (state.phase === "idle" || state.phase === "done") return [];
const total = state.questions.length;
const items: GuidedThreadItem[] = state.outcomes.map((o) => ({
key: `guided-q-${o.index}`,
kind: "question",
question: o.question,
progress: { current: o.index + 1, total },
anchorAnswer: o.answer,
}));
if (state.phase === "asking") {
items.push({
key: `guided-q-${state.index}`,
kind: "question",
question: state.questions[state.index],
progress: { current: state.index + 1, total },
current: true,
anchorAnswer: null,
});
}
if (state.phase === "summary") {
items.push({
key: "guided-summary",
kind: "summary",
summary: {
answered: state.outcomes.length,
adopted: countAdopted(state.outcomes),
total,
},
anchorAnswer: null,
});
}
return items;
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -59,6 +59,74 @@ export interface MessageThreadProps {
optimisticUser?: CoachOptimisticUserMessage | null;
/** Empty-state copy when no conversation is loaded yet. */
emptyHint?: string;
/**
* v1.16.5 — locally-rendered bubbles the guided clarifying-questions
* flow interleaves with the persisted history (deterministic Coach
* questions + the closing summary). Items anchored on an answer
* render immediately before the user message that answered them;
* unanchored items render at the thread tail. See `placeInterleaved`.
*/
interleaved?: InterleavedThreadItem[];
}
/**
* v1.16.5 — one locally-rendered thread bubble contributed by the
* guided clarifying-questions flow. The thread owns only the placement;
* the node's behaviour lives with the flow in `coach-conversation`.
*/
export interface InterleavedThreadItem {
key: string;
/**
* Content of the user message this item precedes (a guided question
* renders above its answer). `null` → render at the thread tail
* (the current question / the summary).
*/
anchorAnswer: string | null;
node: React.ReactNode;
}
/**
* Pure placement for interleaved items, exported for unit tests.
* Items are chronological by construction (the guided flow emits them
* in question order), so a single forward pointer suffices: each
* anchored item consumes the first remaining user message whose
* content equals its anchor. Anchors that never match (e.g. an errored
* turn whose message was never persisted) fall through to the tail so
* no bubble is ever silently dropped.
*/
export function placeInterleaved(
items: InterleavedThreadItem[],
messages: { id: string; role: string; content: string }[],
optimisticContent: string | null,
): {
before: Map<string, InterleavedThreadItem>;
beforeOptimistic: InterleavedThreadItem[];
tail: InterleavedThreadItem[];
} {
const anchored = items.filter((i) => i.anchorAnswer !== null);
const before = new Map<string, InterleavedThreadItem>();
let p = 0;
for (const m of messages) {
if (p >= anchored.length) break;
if (m.role === "user" && m.content === anchored[p].anchorAnswer) {
before.set(m.id, anchored[p]);
p += 1;
}
}
const beforeOptimistic: InterleavedThreadItem[] = [];
if (
p < anchored.length &&
optimisticContent !== null &&
optimisticContent === anchored[p].anchorAnswer
) {
beforeOptimistic.push(anchored[p]);
p += 1;
}
const tail = [
...anchored.slice(p),
...items.filter((i) => i.anchorAnswer === null),
];
return { before, beforeOptimistic, tail };
}
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
@@ -112,6 +180,7 @@ export function MessageThread({
streaming,
optimisticUser,
emptyHint,
interleaved,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -129,6 +198,8 @@ export function MessageThread({
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.16.5 — locally-rendered guided bubbles (see `placeInterleaved`).
const interleavedItems = interleaved ?? [];
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
@@ -187,6 +258,15 @@ export function MessageThread({
return true;
})();
// v1.16.5 — slot the guided bubbles around the persisted history,
// the optimistic user bubble, and the streaming tail. Cheap on every
// render: at most a handful of items over a single message walk.
const placement = placeInterleaved(
interleavedItems,
messages,
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -203,6 +283,7 @@ export function MessageThread({
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
// localId so the user sees their own bubble land at the bottom.
// v1.16.5 — guided bubbles count as new messages for the auto-scroll.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -210,7 +291,12 @@ export function MessageThread({
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}
}, [messages.length, streaming?.content, optimisticUser?.localId]);
}, [
messages.length,
streaming?.content,
optimisticUser?.localId,
interleavedItems.length,
]);
// v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the
// visual viewport shrinks (typically the soft keyboard opening on
@@ -234,7 +320,12 @@ export function MessageThread({
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (messages.length === 0 && !streamingActive && !optimisticActive) {
if (
messages.length === 0 &&
!streamingActive &&
!optimisticActive &&
interleavedItems.length === 0
) {
return (
<div
data-slot="coach-message-thread"
@@ -269,15 +360,20 @@ export function MessageThread({
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
if (m.id === suppressedTwinId) return null;
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
return (
<ChatBubble
key={m.id}
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
/>
<Fragment key={m.id}>
{guidedBefore?.node}
<ChatBubble
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
/>
</Fragment>
);
})}
{/* v1.4.25 W5 — optimistic user bubble surfaces between the
@@ -285,6 +381,10 @@ export function MessageThread({
the visible order matches the user's mental model. The
send-hook drops it as soon as the SSE `done` frame fires +
the invalidate-refetch lands the persisted twin. */}
{/* v1.16.5 — guided question whose answer is still optimistic-only. */}
{placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{optimisticActive && optimisticUser && (
<ChatBubble
key={optimisticUser.localId}
@@ -308,6 +408,11 @@ export function MessageThread({
/>
</div>
)}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
</div>
);
}
@@ -593,8 +698,12 @@ function ChatBubble({
* with staggered delays so no custom keyframe is introduced;
* `motion-reduce` freezes the dots and the `label` stays as the
* screen-reader text either way.
*
* Exported since v1.16.5: the guided clarifying-question bubble replays
* the same indicator before a deterministic question reveals, so the
* scripted turns share one rhythm with the streamed ones.
*/
function TypingDots({ label }: { label: string }) {
export function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
@@ -28,12 +28,23 @@ export interface SelfContextAdoptOfferProps {
answer: string;
/** Remove the strip (declined, or confirmation elapsed). */
onDismiss: () => void;
/**
* v1.16.5 — reports the offer's outcome once, so the guided
* clarifying-questions flow can count adoptions for its closing
* summary. Fired on server settle (adopted / duplicate), on a failed
* request, and on an explicit decline — never on the auto-fold
* timeout (the outcome was already reported by then).
*/
onSettled?: (
outcome: "adopted" | "duplicate" | "declined" | "failed",
) => void;
}
export function SelfContextAdoptOffer({
question,
answer,
onDismiss,
onSettled,
}: SelfContextAdoptOfferProps) {
const { t } = useTranslations();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
@@ -44,6 +55,10 @@ export function SelfContextAdoptOffer({
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
onSettled?.(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
onSettled?.("failed");
},
});
@@ -108,7 +123,10 @@ export function SelfContextAdoptOffer({
type="button"
aria-label={t("insights.coach.selfContextAdopt.dismiss")}
data-slot="coach-self-context-adopt-dismiss"
onClick={onDismiss}
onClick={() => {
onSettled?.("declined");
onDismiss();
}}
className={cn(
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
@@ -1,123 +0,0 @@
"use client";
/**
* v1.16.0 — pending self-context questions as composer chips.
*
* After the user saves Settings → AI → "About me", the server derives
* up to 3 clarifying questions (`/api/coach/about-me/questions`). They
* surface here, directly above the Coach composer, as quiet tappable
* chips: tapping one inserts the question into the composer (so the
* user answers it in their own words) and dismisses it; the ✕
* dismisses without inserting. No questions → nothing renders, zero
* vertical cost.
*
* Each chip is a `role="group"` of two sibling buttons (insert +
* dismiss) — buttons cannot nest, and both actions deserve their own
* focus stop.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MessageCircleQuestion, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
export interface SelfContextChipsProps {
/** Insert the tapped question into the composer. */
onPick: (question: string) => void;
/** Hidden while a reply streams so the input value cannot be yanked. */
disabled?: boolean;
}
export function SelfContextChips({
onPick,
disabled = false,
}: SelfContextChipsProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.coachAboutMeQuestions(),
queryFn: async () => {
return apiGet<{ questions: string[] }>("/api/coach/about-me/questions");
},
staleTime: 60_000,
});
const dismiss = useMutation({
mutationKey: queryKeys.coachAboutMeQuestions(),
mutationFn: async (question: string) => {
return apiDelete<{ questions: string[] }>("/api/coach/about-me/questions", { question });
},
onSuccess: (next) => {
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
},
// v1.16.4 — a failed dismiss used to fail silently, leaving the chip
// in place with no signal that the request was rejected.
onError: () => {
toast.error(t("insights.coach.selfContextChipDismissError"));
},
});
const questions = data?.questions ?? [];
if (questions.length === 0) return null;
return (
<div data-slot="coach-self-context-chips" className="mb-2">
<p className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-[11px]">
<MessageCircleQuestion
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
{t("insights.coach.selfContextChipsLabel")}
</p>
<ul className="flex flex-wrap gap-1.5">
{questions.map((question) => (
<li
key={question}
role="group"
aria-label={question}
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 inline-flex max-w-full items-stretch overflow-hidden rounded-full border",
"transition-colors",
)}
>
<button
type="button"
disabled={disabled}
data-slot="coach-self-context-chip"
onClick={() => {
onPick(question);
dismiss.mutate(question);
}}
title={question}
className={cn(
"text-foreground min-h-9 min-w-0 truncate py-1.5 pr-1 pl-3 text-left text-xs",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{question}
</button>
<button
type="button"
disabled={disabled}
aria-label={t("insights.coach.selfContextChipDismiss")}
data-slot="coach-self-context-chip-dismiss"
onClick={() => dismiss.mutate(question)}
className={cn(
"text-muted-foreground hover:text-foreground flex items-center px-2",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
<X className="size-3" aria-hidden="true" />
</button>
</li>
))}
</ul>
</div>
);
}
@@ -236,6 +236,12 @@ export interface SendCoachMessageParams {
* back to all-source last30days, matching the legacy behaviour.
*/
scope?: CoachScope;
/**
* v1.16.6 — guided clarifying-questions flow: the pending question
* this message answers. Server-side prompt context only; never
* persisted with the message.
*/
guidedQuestion?: string;
}
export interface UseSendCoachMessageOptions {
@@ -286,7 +292,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}, []);
const send = useCallback(
async (params: SendCoachMessageParams): Promise<void> => {
async (
params: SendCoachMessageParams,
): Promise<string | null> => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
@@ -322,7 +330,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: "coach.network",
});
return;
return null;
}
// apiFetchRaw: the chat POST streams SSE — the envelope helpers
@@ -341,11 +349,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
prefill: params.prefill,
locale: params.locale,
scope: params.scope,
guidedQuestion: params.guidedQuestion,
}),
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
if ((err as Error).name === "AbortError") return null;
setStreaming({
content: "",
metricSource: null,
@@ -353,7 +362,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: "coach.network",
});
return;
return null;
}
if (!response.body) {
@@ -364,7 +373,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: `coach.http.${response.status}`,
});
return;
return null;
}
// Even on `!response.ok`, the route may have emitted a structured
@@ -405,7 +414,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// the bubble left the error standing next to an empty thread.
// The content-equality dedupe in <MessageThread> still
// suppresses the optimistic copy if a persisted twin lands.
return;
return null;
}
const reader = response.body.getReader();
@@ -495,6 +504,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (resolvedConversationId || !lastError) {
setOptimisticUser(null);
}
return resolvedConversationId;
},
[queryClient],
);
@@ -470,3 +470,74 @@ describe("medication card body — shared shell + dose-state presentation", () =
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
});
});
describe("compliance bars — fixed geometry (v1.16.6)", () => {
/**
* The two percentage rows must keep an IDENTICAL, fixed geometry no
* matter the rates, labels, or any status colouring around them: each
* label line is pinned to one text line (`h-5` + `truncate`) and each
* bar to `h-2`. A regression here lets a wrapping label or a
* status-dependent caption make the two bars sit at unequal heights
* with stray space below (the v1.16.4 card report).
*/
function geometrySignature(html: string): {
labelRows: number;
bars: number;
truncated: number;
} {
return {
labelRows: (html.match(/flex h-5 items-center justify-between/g) ?? [])
.length,
bars: (html.match(/data-slot="progress"/g) ?? []).length,
truncated: (html.match(/truncate/g) ?? []).length,
};
}
it("pins both rows to the same fixed-height markup regardless of rates", () => {
const low = render(
<MedicationComplianceBars rate7={0} rate30={13} streak={0} />,
);
const high = render(
<MedicationComplianceBars rate7={100} rate30={100} streak={9} />,
);
const wide = render(
<MedicationComplianceBars
rate7={50}
rate30={50}
streak={0}
shortDays={90}
longDays={365}
/>,
);
for (const html of [low, high, wide]) {
const sig = geometrySignature(html);
expect(sig.labelRows).toBe(2);
expect(sig.bars).toBe(2);
expect(sig.truncated).toBe(2);
// Both bars carry the same fixed track height.
expect(html.match(/data-slot="progress"[^>]*h-2/g) ?? []).toHaveLength(2);
}
});
it("rate / streak changes alter text only, never the geometry classes", () => {
const a = render(
<MedicationComplianceBars rate7={0} rate30={0} streak={0} />,
);
const b = render(
<MedicationComplianceBars rate7={100} rate30={42} streak={0} />,
);
// Strip text nodes; the remaining tag/class skeleton must be identical
// between a 0% and a 100% card (transform style varies with the value
// by design, so it is normalised too).
const skeleton = (html: string) =>
html
.replace(/>[^<]+</g, "><")
.replace(/transform:[^;"]+/g, "transform:X")
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
.replace(/data-state="[^"]*"/g, 'data-state="S"')
.replace(/data-value="[^"]*"/g, 'data-value="V"')
.replace(/data-max="[^"]*"/g, 'data-max="M"');
expect(skeleton(a)).toBe(skeleton(b));
});
});
@@ -61,27 +61,35 @@ export function MedicationComplianceBars({
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
// so neither a long locale label wrapping, nor the `?` trigger, nor any
// status-coloured caption can make the two rows differ in height. Status
// is colour / text only — never geometry; the two bars on a card (and
// across a grid row) always align.
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
{shortLabel}
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
<span className="truncate">{shortLabel}</span>
{/* One `?` for the whole block — both rows measure the same
thing over different windows, so a second trigger on the
long row would only add noise. */}
<ComplianceInfoTip />
</span>
<span className="font-medium">{shortPct}%</span>
<span className="shrink-0 font-medium">{shortPct}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">{longPct}%</span>
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground min-w-0 truncate">
{longLabel}
</span>
<span className="shrink-0 font-medium">{longPct}%</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -121,14 +129,14 @@ export function MedicationComplianceSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex h-5 items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
<div className="bg-muted h-2 rounded" />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex h-5 items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
@@ -50,6 +50,7 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor";
import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { InventorySection } from "@/components/medications/sections/inventory-section";
@@ -536,6 +537,24 @@ export function MedicationDetailTabs({
</p>
</div>
</MedicationDetailSection>
{/* PLANHISTORIE — archived schedule eras (v1.16.3 effective
dating) as a quiet collapsible timeline, plus the manual
pre-tracking-era flow. Read-only for write-path archives. */}
<MedicationDetailSection
titleId="medication-zeitplan-history-heading"
title={t("medications.detail.zeitplan.history.title")}
dataSlot="medication-zeitplan-history"
>
<ScheduleHistoryTimeline
medicationId={id}
currentTimes={medication.schedules.flatMap((s) =>
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
)}
/>
</MedicationDetailSection>
</TabsContent>
{/* BESTAND — inventory readout + register / correct flows. */}
@@ -318,6 +318,16 @@ export function Glp1MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next injection is days away can never paint an overdue
// pill today. `undefined` (older payloads / fixtures) keeps legacy
// behaviour; `next` is already the parsed-and-validated `nextDueAt`.
nextDue:
medication.nextDueAt === undefined
? undefined
: next
? { at: next.date, overdue: medication.nextDueOverdue === true }
: null,
});
// v1.12.3 — slot instant of the injection this card is showing (the
@@ -251,6 +251,15 @@ export function MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next dose is tomorrow can never paint an overdue pill
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
nextDue:
medication.nextDueAt === undefined
? undefined
: nextAt !== undefined
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
: null,
});
// v1.12.3 — the slot instant of the dose this card is currently showing
@@ -0,0 +1,186 @@
/**
* v1.16.5 — schedule-history timeline SSR smoke tests.
*
* Project convention: `renderToStaticMarkup` + assertions against the
* SSR string, react-query data seeded via `setQueryData` so `useQuery`
* resolves synchronously (no `@testing-library/react` installed).
* Interactive branches (toggle click, dialog submit, delete confirm)
* are covered by the route tests + the `defaultExpanded` render path
* here; the contracts pinned:
*
* 1. Live plan row: deduped/sorted times + "current since" line.
* 2. Collapsed default: era rows absent, toggle carries the count.
* 3. Expanded: each era renders times + date range; MANUAL eras get
* the chip + delete affordance, ARCHIVED eras get neither.
* 4. No revisions: no toggle, the add-era CTA still renders.
* 5. `eraTimes` dedupes/sorts across entries and drops non-arrays.
* 6. v1.16.6: every era row (MANUAL and ARCHIVED) carries the edit
* pencil; only MANUAL rows carry delete.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
ScheduleHistoryTimeline,
eraTimes,
type ScheduleRevisionRow,
} from "@/components/medications/scheduling/schedule-history-timeline";
import { queryKeys } from "@/lib/query-keys";
const MED_ID = "med-1";
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
function seed(
client: QueryClient,
data: { currentSince: string; revisions: ScheduleRevisionRow[] },
) {
client.setQueryData(queryKeys.medicationScheduleRevisions(MED_ID), data);
}
function render(
client: QueryClient,
props?: Partial<Parameters<typeof ScheduleHistoryTimeline>[0]>,
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">
<QueryClientProvider client={client}>
<ScheduleHistoryTimeline
medicationId={MED_ID}
currentTimes={["20:00", "08:00", "08:00"]}
{...props}
/>
</QueryClientProvider>
</I18nProvider>,
);
}
const MANUAL_ERA: ScheduleRevisionRow = {
id: "rev-manual",
validFrom: "2026-03-12T00:00:00.000Z",
validUntil: "2026-06-01T00:00:00.000Z",
source: "MANUAL",
entries: [
{
timesOfDay: ["07:00", "19:00"],
label: null,
dose: null,
scheduleType: "SCHEDULED",
},
],
};
const ARCHIVED_ERA: ScheduleRevisionRow = {
id: "rev-archived",
validFrom: "2026-01-10T00:00:00.000Z",
validUntil: "2026-03-12T00:00:00.000Z",
source: "ARCHIVED",
entries: [
{
timesOfDay: ["09:00"],
label: "Morgens",
dose: null,
scheduleType: "SCHEDULED",
},
],
};
describe("ScheduleHistoryTimeline", () => {
it("renders the live plan with deduped, sorted times and the since line", () => {
const client = makeClient();
seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] });
const html = render(client);
expect(html).toContain("08:00 / 20:00");
expect(html).toContain("Aktueller Plan seit");
expect(html).toContain("01.06.2026");
});
it("collapses predecessor eras by default and counts them in the toggle", () => {
const client = makeClient();
seed(client, {
currentSince: "2026-06-01T00:00:00.000Z",
revisions: [MANUAL_ERA, ARCHIVED_ERA],
});
const html = render(client);
expect(html).toContain("2 frühere Ären anzeigen");
expect(html).not.toContain("07:00 / 19:00");
expect(html).not.toContain("zeitplan-history-era");
});
it("expanded: era rows carry times, range, and MANUAL-only affordances", () => {
const client = makeClient();
seed(client, {
currentSince: "2026-06-01T00:00:00.000Z",
revisions: [MANUAL_ERA, ARCHIVED_ERA],
});
const html = render(client, { defaultExpanded: true });
expect(html).toContain("07:00 / 19:00");
expect(html).toContain("12.03.2026");
expect(html).toContain("01.06.2026");
expect(html).toContain("Manuell ergänzt");
expect(html).toContain("Ära löschen");
// Exactly ONE manual chip + ONE delete button — the archived era
// renders without delete.
expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1);
expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1);
// BOTH eras carry the edit pencil: a MANUAL era edits in place, an
// ARCHIVED era is corrected via a superseding revision.
expect(html.match(/zeitplan-history-edit/g)).toHaveLength(2);
expect(html).toContain("Ära bearbeiten");
expect(html).toContain("Frühere Ären ausblenden");
});
it("renders the add-era CTA without a toggle when no era exists", () => {
const client = makeClient();
seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] });
const html = render(client);
expect(html).toContain("Frühere Ära ergänzen");
expect(html).not.toContain("zeitplan-history-toggle");
});
});
describe("eraTimes", () => {
it("dedupes and sorts across entries", () => {
expect(
eraTimes([
{
timesOfDay: ["19:00", "07:00"],
label: null,
dose: null,
scheduleType: "SCHEDULED",
},
{
timesOfDay: ["07:00", "12:00"],
label: null,
dose: null,
scheduleType: "SCHEDULED",
},
]),
).toEqual(["07:00", "12:00", "19:00"]);
});
it("tolerates a non-array timesOfDay", () => {
expect(
eraTimes([
{
timesOfDay: undefined as unknown as string[],
label: null,
dose: null,
scheduleType: "PRN",
},
]),
).toEqual([]);
});
});
@@ -0,0 +1,613 @@
"use client";
/**
* v1.16.5 — Zeitplan-tab schedule-history timeline.
*
* The ledger and compliance tallies have minted past days against
* archived schedule eras since v1.16.3; this is the missing management
* surface. A quiet vertical timeline under the times editor:
*
* - the live plan as the accent node ("Aktueller Plan seit {Datum}",
* with its times when the parent passes them),
* - predecessor eras collapsed behind a count toggle — each row reads
* "07:00 / 19:00 · 12.03.2026 – 01.06.2026", read-only,
* - a "Frühere Ära ergänzen" flow for pre-tracking history: the
* dialog collects daily times (the existing `TimesOfDayChips`
* idiom) plus a Von/Bis date pair and POSTs a MANUAL revision,
* - MANUAL eras carry a tinted chip and a delete affordance
* (confirmed via AlertDialog); write-path archives are immutable
* and render without one.
* - v1.16.6: every era row carries an edit pencil. The dialog opens
* prefilled like the add flow; a MANUAL era PATCHes in place, an
* ARCHIVED era shows a quiet hint that the change corrects the
* recorded history (the server mints a superseding MANUAL row and
* keeps the original as the audit record).
*
* The "Bis" date is the day the NEXT plan took over (exclusive bound) —
* the dialog copy says so, and adjacency with the live plan is the
* common case. Dates convert to instants at the browser's local
* midnight, matching the user's wall clock like every schedule input.
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
History,
Loader2,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
interface RevisionEntrySummary {
timesOfDay: string[];
label: string | null;
dose: string | null;
scheduleType: string;
}
export interface ScheduleRevisionRow {
id: string;
validFrom: string;
validUntil: string;
source: "ARCHIVED" | "MANUAL";
entries: RevisionEntrySummary[];
}
interface ScheduleRevisionsResponse {
currentSince: string;
revisions: ScheduleRevisionRow[];
}
/**
* Dose-time readout of one era: the union of its entries' times,
* deduped + sorted ("07:00 / 19:00"). PRN-only eras have no times —
* the caller renders its own placeholder for an empty result.
*/
export function eraTimes(entries: RevisionEntrySummary[]): string[] {
const all = entries.flatMap((e) =>
Array.isArray(e.timesOfDay) ? e.timesOfDay : [],
);
return [...new Set(all)].sort((a, b) => a.localeCompare(b));
}
export function ScheduleHistoryTimeline({
medicationId,
currentTimes,
defaultExpanded = false,
}: {
medicationId: string;
/** Live-plan dose times the parent already holds (deduped here). */
currentTimes: string[];
/** Start with the predecessor eras unfolded (default collapsed). */
defaultExpanded?: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(defaultExpanded);
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ScheduleRevisionRow | null>(
null,
);
const [deleteTarget, setDeleteTarget] = useState<ScheduleRevisionRow | null>(
null,
);
const { data, isError } = useQuery({
queryKey: queryKeys.medicationScheduleRevisions(medicationId),
queryFn: async () =>
apiGet<ScheduleRevisionsResponse>(
`/api/medications/${medicationId}/schedule-revisions`,
),
staleTime: 30_000,
});
const liveTimes = useMemo(
() => [...new Set(currentTimes)].sort((a, b) => a.localeCompare(b)),
[currentTimes],
);
const remove = useMutation({
mutationFn: async (revisionId: string) => {
await apiDelete(
`/api/medications/${medicationId}/schedule-revisions/${revisionId}`,
);
},
onSuccess: () => {
toast.success(t("medications.detail.zeitplan.history.deletedToast"));
// An era delete re-segments history — the ledger, compliance, and
// cadence reads under this medication's prefix all go stale.
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
});
},
onError: () => {
toast.error(t("medications.detail.zeitplan.history.deleteError"));
},
});
if (isError) {
return (
<p className="text-muted-foreground text-sm" data-slot="zeitplan-history">
{t("medications.detail.zeitplan.history.loadError")}
</p>
);
}
if (!data) return null;
const revisions = data.revisions;
return (
<div className="space-y-3" data-slot="zeitplan-history">
<ol className="space-y-0">
{/* Live plan — the accent node. */}
<TimelineRow
dotClassName="bg-dracula-green"
railVisible={expanded && revisions.length > 0}
dataSlot="zeitplan-history-current"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium tabular-nums">
{liveTimes.length > 0
? liveTimes.join(" / ")
: t("medications.detail.zeitplan.history.noTimes")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.currentSince", {
date: fmt.date(data.currentSince),
})}
</p>
</div>
</TimelineRow>
{/* Predecessor eras — newest first, only when expanded. */}
{expanded &&
revisions.map((revision, idx) => {
const times = eraTimes(revision.entries);
return (
<TimelineRow
key={revision.id}
dotClassName="bg-muted-foreground/50"
railVisible={idx < revisions.length - 1}
dataSlot="zeitplan-history-era"
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-foreground text-sm tabular-nums">
{times.length > 0
? times.join(" / ")
: t("medications.detail.zeitplan.history.noTimes")}
{revision.source === "MANUAL" && (
<span
className="border-dracula-purple/30 bg-dracula-purple/10 text-dracula-purple ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
data-slot="zeitplan-history-manual-chip"
>
{t("medications.detail.zeitplan.history.manualChip")}
</span>
)}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
{fmt.date(revision.validUntil)}
</p>
</div>
<div className="flex shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-8"
aria-label={t(
"medications.detail.zeitplan.history.editAria",
)}
onClick={() => setEditTarget(revision)}
data-slot="zeitplan-history-edit"
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
{revision.source === "MANUAL" && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-dracula-red size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
)}
disabled={remove.isPending}
onClick={() => setDeleteTarget(revision)}
data-slot="zeitplan-history-delete"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
</TimelineRow>
);
})}
</ol>
<div className="flex flex-wrap items-center gap-2">
{revisions.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
data-slot="zeitplan-history-toggle"
>
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 transition-transform motion-reduce:transition-none",
expanded && "rotate-180",
)}
/>
{expanded
? t("medications.detail.zeitplan.history.hideEras")
: revisions.length === 1
? t("medications.detail.zeitplan.history.showErasOne")
: t("medications.detail.zeitplan.history.showErasOther", {
count: revisions.length,
})}
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
data-slot="zeitplan-history-add"
>
<Plus className="size-4" aria-hidden="true" />
{t("medications.detail.zeitplan.history.addEra")}
</Button>
</div>
<EraDialog
key={editTarget?.id ?? "add"}
medicationId={medicationId}
target={editTarget}
open={addOpen || editTarget !== null}
onOpenChange={(next) => {
if (!next) {
setAddOpen(false);
setEditTarget(null);
}
}}
onSaved={() => setExpanded(true)}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zeitplan.history.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zeitplan.history.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("medications.detail.zeitplan.history.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) remove.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
>
{t("medications.detail.zeitplan.history.deleteConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
/**
* 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 (
<li className="relative pb-4 pl-5 last:pb-0" data-slot={dataSlot}>
<span
aria-hidden="true"
className={cn(
"absolute top-1.5 left-0 size-2 rounded-full",
dotClassName,
)}
/>
{railVisible && (
<span
aria-hidden="true"
className="bg-border/70 absolute top-4 bottom-0 left-[3.5px] w-px"
/>
)}
{children}
</li>
);
}
/** ISO instant → the local calendar date a `DateInput` understands. */
function toLocalDateInput(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* "Frühere Ära ergänzen" / "Ära bearbeiten" — collects daily dose times
* + the Von/Bis date pair. Without a `target` it POSTs a MANUAL
* revision; with one it PATCHes the era (a MANUAL era updates in place,
* an ARCHIVED era gets a superseding correction — the dialog shows a
* quiet hint that the recorded history is being corrected). The parent
* remounts the dialog via `key` per target, so the prefill is a plain
* lazy state init. 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 EraDialog({
medicationId,
target,
open,
onOpenChange,
onSaved,
}: {
medicationId: string;
/** Era being edited; null = the add flow. */
target: ScheduleRevisionRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [times, setTimes] = useState<string[]>(() =>
target ? eraTimes(target.entries) : [],
);
const [fromDate, setFromDate] = useState(() =>
target ? toLocalDateInput(target.validFrom) : "",
);
const [untilDate, setUntilDate] = useState(() =>
target ? toLocalDateInput(target.validUntil) : "",
);
const [validationKey, setValidationKey] = useState<
"timesMissing" | "rangeMissing" | "rangeOrder" | null
>(null);
function reset() {
setTimes(target ? eraTimes(target.entries) : []);
setFromDate(target ? toLocalDateInput(target.validFrom) : "");
setUntilDate(target ? toLocalDateInput(target.validUntil) : "");
setValidationKey(null);
}
const create = useMutation({
mutationFn: async (input: {
validFrom: string;
validUntil: string;
timesOfDay: string[];
}) =>
target
? apiPatch<ScheduleRevisionRow>(
`/api/medications/${medicationId}/schedule-revisions/${target.id}`,
input,
)
: apiPost<ScheduleRevisionRow>(
`/api/medications/${medicationId}/schedule-revisions`,
input,
),
onSuccess: () => {
toast.success(
target
? t("medications.detail.zeitplan.history.updatedToast")
: t("medications.detail.zeitplan.history.createdToast"),
);
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
});
onOpenChange(false);
reset();
onSaved();
},
onError: () => {
toast.error(
target
? t("medications.detail.zeitplan.history.updateError")
: 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 (
<Dialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")}
</DialogTitle>
<DialogDescription>
{target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")}
</DialogDescription>
</DialogHeader>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-history-archived-hint"
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submit();
}}
className="space-y-4"
>
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("medications.detail.zeitplan.history.timesLabel")}
</p>
<TimesOfDayChips value={times} onChange={setTimes} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateInput
id="zeitplan-history-from"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
data-slot="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateInput
id="zeitplan-history-until"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
data-slot="zeitplan-history-until"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.untilHint")}
</p>
{validationKey !== null && (
<p
className="text-dracula-red text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
{validationKey === "timesMissing" &&
t("medications.detail.zeitplan.history.validationTimes")}
{validationKey === "rangeMissing" &&
t("medications.detail.zeitplan.history.validationRangeMissing")}
{validationKey === "rangeOrder" &&
t("medications.detail.zeitplan.history.validationRangeOrder")}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
{t("medications.detail.zeitplan.history.cancel")}
</Button>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-slot="zeitplan-history-submit"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<History className="size-4" aria-hidden="true" />
)}
{t("medications.detail.zeitplan.history.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+29 -1
View File
@@ -1,6 +1,11 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryClient,
QueryClientProvider,
useQueryClient,
} from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import {
createContext,
useCallback,
@@ -14,6 +19,8 @@ import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
// ── Theme Context ────────────────────────────────────
@@ -109,6 +116,26 @@ function ThemeProvider({ children }: { children: ReactNode }) {
);
}
// ── Dashboard snapshot preloader ─────────────────────
//
// v1.16.6 first-load waterfall fix: fire the snapshot fetch the moment
// the router commits to "/" instead of waiting for the dashboard page
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
// The proxy has already enforced auth + onboarding for "/" before any
// client code runs here, so the prefetch cannot fire for an
// unauthenticated visitor; a racing edge case just yields a swallowed
// 401 prefetch and the mounted cell re-resolves normally.
function DashboardSnapshotPreloader() {
const queryClient = useQueryClient();
const pathname = usePathname();
useEffect(() => {
if (pathname === "/" && isDashboardSnapshotEnabled()) {
prefetchDashboardSnapshot(queryClient);
}
}, [pathname, queryClient]);
return null;
}
// ── Root Providers ───────────────────────────────────
export function Providers({
@@ -143,6 +170,7 @@ export function Providers({
initialMessages={initialMessages}
>
<AppSettingsProvider>
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
@@ -121,7 +121,9 @@ describe("<UpdateBadge> — a11y contract", () => {
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain(
'aria-label="A newer version is available: v1.4.99"',
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
expect(html).toContain("focus-visible:ring-ring");
@@ -140,7 +142,9 @@ describe("<UpdateBadge> — a11y contract", () => {
/>,
);
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain(
'aria-label="A newer version is available: v1.4.99"',
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
@@ -44,9 +44,9 @@ describe("resolveInitialTimezone — v1.4.37 silent auto-seed", () => {
});
it("respects any non-Berlin stored value — explicit user choice wins over browser detection", () => {
expect(
resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin"),
).toBe("America/Los_Angeles");
expect(resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin")).toBe(
"America/Los_Angeles",
);
});
it("respects a non-Berlin stored value even when the browser reports a different non-Berlin zone", () => {
@@ -30,9 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
}));
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: () => (
<div data-slot="mock-ack-dialog" />
),
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
}));
import { AdvancedSection } from "../advanced-section";
@@ -137,9 +137,7 @@ describe("<AdvancedSection> → Research Mode card", () => {
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
expect(html).toContain("Disclaimer updated");
expect(html).toContain(
'data-slot="settings-research-mode-reprompt-cta"',
);
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
expect(html).toContain("Re-acknowledge disclaimer");
// Status line reflects the stale state without claiming
// "acknowledged on …" — that copy would mis-represent that the
@@ -290,17 +290,19 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
const pinOnlyLabels: Record<(typeof IOS_PIN_ONLY_WIDGET_IDS)[number], string> =
{
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
wristTemperature: "Wrist temperature",
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
const pinOnlyLabels: Record<
(typeof IOS_PIN_ONLY_WIDGET_IDS)[number],
string
> = {
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
wristTemperature: "Wrist temperature",
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
// The widget label drives the switch aria-label; if the row were
// rendered the localised label would show up in the markup.
@@ -27,11 +27,7 @@ vi.mock("next/navigation", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
ImportPanel,
EXAMPLE_IMPORT,
parseImportJson,
} from "../import-panel";
import { ImportPanel, EXAMPLE_IMPORT, parseImportJson } from "../import-panel";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
@@ -61,11 +61,12 @@ beforeEach(() => {
}));
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
vi.fn(
async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
});
@@ -113,7 +113,9 @@ describe("Settings — TimeFormatSelect", () => {
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
expect(html).toMatch(
/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/,
);
});
it("defaults to AUTO when the user has no stored preference", () => {
@@ -122,14 +124,14 @@ describe("Settings — TimeFormatSelect", () => {
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
expect(html).toMatch(
/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/,
);
});
it("disables the select when unauthenticated", () => {
const html = render(false);
const select = html.match(
/<select[^>]*settings-time-format-select[^>]*>/,
);
const select = html.match(/<select[^>]*settings-time-format-select[^>]*>/);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
+1 -1
View File
@@ -184,7 +184,7 @@ export function AboutMeSection({
<section
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="bg-card border-border space-y-5 rounded-xl border p-6"
className="bg-card border-border space-y-5 rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
+4 -7
View File
@@ -234,10 +234,7 @@ export function AboutSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="sr-only"
>
<h1 id="settings-section-about-title" className="sr-only">
{t("settings.sections.about.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -249,7 +246,7 @@ export function AboutSection() {
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
{isLoading || !version ? (
@@ -319,7 +316,7 @@ export function AboutSection() {
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={BookOpen}
title={t("settings.about.linksHeading")}
@@ -368,7 +365,7 @@ export function AboutSection() {
links. Mirrors the stack-on-mobile / right-align-on-desktop
contract used by the Account section's "Restart onboarding
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
+6 -9
View File
@@ -414,10 +414,7 @@ export function AccountSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-account-title"
className="sr-only"
>
<h1 id="settings-section-account-title" className="sr-only">
{t("settings.sections.account.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -429,7 +426,7 @@ export function AccountSection() {
{/* Profile photo card */}
<AvatarSection />
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
@@ -648,7 +645,7 @@ export function AccountSection() {
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Shield}
title={t("settings.passkeys")}
@@ -692,7 +689,7 @@ export function AccountSection() {
(the German "Passwort ändern" already pushes it; the tour
card's "Restart onboarding tour" actually broke through the
right border by ~48 px). */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
@@ -722,7 +719,7 @@ export function AccountSection() {
background reopens the spotlight tour immediately. v1.4.19
A6 — same stack-on-mobile / right-align-on-desktop contract
as the password card so both action surfaces look identical. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
@@ -1190,7 +1187,7 @@ function AvatarSection() {
const busy = upload.isPending || remove.isPending;
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
+10 -16
View File
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
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 {
@@ -56,10 +57,7 @@ export function AdvancedSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="sr-only"
>
<h1 id="settings-section-advanced-title" className="sr-only">
{t("settings.sections.advanced.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -151,18 +149,14 @@ function ResearchModeCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("settings.researchMode.subtitle")}
</p>
<SettingsCardHeader
icon={BookOpenCheck}
title={t("settings.researchMode.sectionTitle")}
description={t("settings.researchMode.subtitle")}
/>
{showRePrompt && (
<div
@@ -270,7 +264,7 @@ function DataResetCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-data-reset-card"
>
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
@@ -418,7 +412,7 @@ function AccountDeleteCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-account-delete-card"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
+18 -10
View File
@@ -16,23 +16,31 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
// cards would then disagree with the SSR HTML (React #418). Gate the
// prop on `useMounted()` so the hydration render matches the
// server-rendered "not yet authenticated" state and the cards enable
// on the first client re-render.
const mounted = useMounted();
const authed = mounted && isAuthenticated;
// v1.11.2 — the Coach-memory panel rides the same gate as the rest of
// the Coach surface: when the user has hidden the Coach there is no
// assistant to remember anything, so the memory controls hide too.
const coachEnabled = !user?.disableCoach;
// (`mounted`-gated like `authed`: SSR renders the panels, so the
// hydration render must too, even when the user disabled the Coach.)
const coachEnabled = !mounted || !user?.disableCoach;
return (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-ai-title"
className="sr-only"
>
<h1 id="settings-section-ai-title" className="sr-only">
{t("settings.sections.ai.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -40,27 +48,27 @@ export function AiSection() {
</p>
</header>
<AiInsightsCard isAuthenticated={isAuthenticated} />
<AiInsightsCard isAuthenticated={authed} />
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
card so users who want the surface gone never have to scroll
through provider configuration before finding the toggle. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
<DisableCoachCard isAuthenticated={authed} />
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
moved here from the in-chat sheet; the chat header gear
deep-links to this section. Gated on the Coach being
enabled. */}
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
{/* v1.15.20 — user-authored "about me" context for the Coach +
daily briefing. Not gated on the Coach toggle: the briefing
reads it too. */}
<AboutMeSection isAuthenticated={isAuthenticated} />
<AboutMeSection isAuthenticated={authed} />
</section>
);
}
@@ -119,7 +119,7 @@ export function AiInsightsCard({
}
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
@@ -26,6 +26,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiPatch } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -74,7 +75,12 @@ export function DisableCoachCard({
}, 3000);
}
const checked = optimistic ?? user?.disableCoach ?? false;
// `mounted`-gated: `user` comes from the auth query, which can
// resolve before this boundary hydrates — the Switch state must
// match the unchecked SSR HTML during hydration (React #418) and
// pick up the wire value on the first client re-render.
const mounted = useMounted();
const checked = mounted ? (optimistic ?? user?.disableCoach ?? false) : false;
const mutation = useMutation({
mutationFn: async (next: boolean) => {
@@ -122,7 +128,7 @@ export function DisableCoachCard({
<section
aria-labelledby="settings-ai-disable-coach-title"
data-testid="settings-disable-coach-card"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={MessageCircleOff}
+3 -6
View File
@@ -42,10 +42,7 @@ export function ApiSection() {
return (
<section aria-labelledby="settings-section-api-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-api-title"
className="sr-only"
>
<h1 id="settings-section-api-title" className="sr-only">
{t("settings.sections.api.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -78,7 +75,7 @@ function ApiEndpointsCard() {
];
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Key}
title={t("settings.apiEndpointsTitle")}
@@ -241,7 +238,7 @@ function ApiTokensCard() {
}, null);
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Key}
title={t("settings.apiTokens")}
@@ -20,7 +20,7 @@
*/
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { Brain, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -35,6 +35,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
@@ -136,70 +137,65 @@ export function CoachMemorySection({
<section
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="bg-card border-border space-y-4 rounded-xl border p-6"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="pr-2">
<h2
id="settings-ai-coach-memory-title"
className="text-base font-semibold"
>
{t("settings.ai.coachMemory.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.description")}
</p>
</div>
{facts.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
<SettingsCardHeader
icon={Brain}
titleId="settings-ai-coach-memory-title"
title={t("settings.ai.coachMemory.title")}
description={t("settings.ai.coachMemory.description")}
status={
facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null
}
/>
{query.isError && (
<p
+139 -18
View File
@@ -7,23 +7,67 @@
* `notificationPrefs.coach.nudgesEnabled` through the roaming
* notification-prefs blob so web + iOS share one source of truth.
*
* v1.16.5 — per-group toggles (medication / vitals / routine) plus the
* frequency pref (weekly / biweekly) underneath the master switch. All
* of it rides the same `coach` sub-object of the prefs blob; the cron
* resolves it via `resolveCoachNudgePrefs`.
*
* Mirrors `<MoodReminderCard>` (optimistic switch, auto-clearing inline
* status line).
* status line, NativeSelect for the enum pref).
*/
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MessageCircleHeart } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NotificationPrefsShape {
coach: { nudgesEnabled: boolean };
interface CoachPrefsShape {
nudgesEnabled: boolean;
nudgeMedication: boolean;
nudgeVitals: boolean;
nudgeRoutine: boolean;
nudgeFrequency: "weekly" | "biweekly";
}
interface NotificationPrefsShape {
coach: CoachPrefsShape;
}
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
};
const GROUP_FIELDS = [
"nudgeMedication",
"nudgeVitals",
"nudgeRoutine",
] as const;
type GroupField = (typeof GROUP_FIELDS)[number];
const GROUP_I18N: Record<GroupField, { label: string; desc: string }> = {
nudgeMedication: {
label: "notifications.coachNudge.groupMedication",
desc: "notifications.coachNudge.groupMedicationDesc",
},
nudgeVitals: {
label: "notifications.coachNudge.groupVitals",
desc: "notifications.coachNudge.groupVitalsDesc",
},
nudgeRoutine: {
label: "notifications.coachNudge.groupRoutine",
desc: "notifications.coachNudge.groupRoutineDesc",
},
};
export function CoachNudgeCard({
isAuthenticated,
}: {
@@ -31,7 +75,7 @@ export function CoachNudgeCard({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [optimistic, setOptimistic] = useState<Partial<CoachPrefsShape>>({});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -66,10 +110,23 @@ export function CoachNudgeCard({
enabled: isAuthenticated,
});
const enabled = optimistic ?? prefs?.coach?.nudgesEnabled ?? true;
const resolved: CoachPrefsShape = {
...COACH_PREF_DEFAULTS,
...(prefs?.coach ?? {}),
...optimistic,
};
const enabled = resolved.nudgesEnabled;
async function handleToggle(next: boolean) {
setOptimistic(next);
/**
* One PATCH path for every knob on the card. `toast` carries the
* success line for the master toggle; the sub-prefs pass null and
* stay silent on success — the control itself shows the new state.
*/
async function patchCoach(
partial: Partial<CoachPrefsShape>,
toast: string | null,
) {
setOptimistic((prev) => ({ ...prev, ...partial }));
setSaving(true);
setMsg(null);
setMsgType(null);
@@ -81,23 +138,21 @@ export function CoachNudgeCard({
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coach: { nudgesEnabled: next } }),
body: JSON.stringify({ coach: partial }),
});
if (res.ok) {
setMsg(
next
? t("notifications.coachNudge.enabledToast")
: t("notifications.coachNudge.disabledToast"),
);
setMsgType("success");
if (toast) {
setMsg(toast);
setMsgType("success");
scheduleClear();
}
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
scheduleClear();
setOptimistic({});
} else {
setOptimistic(null);
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
setMsgType("error");
scheduleClear();
@@ -105,10 +160,19 @@ export function CoachNudgeCard({
setSaving(false);
}
async function handleToggle(next: boolean) {
await patchCoach(
{ nudgesEnabled: next },
next
? t("notifications.coachNudge.enabledToast")
: t("notifications.coachNudge.disabledToast"),
);
}
return (
<section
aria-labelledby="settings-coach-nudge-title"
className="bg-card rounded-lg border p-4 sm:p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-testid="settings-coach-nudge-card"
>
<SettingsCardHeader
@@ -132,6 +196,63 @@ export function CoachNudgeCard({
</label>
}
/>
{enabled && (
<div className="mt-4 space-y-3 pl-7">
<p className="text-sm font-medium">
{t("notifications.coachNudge.groupsLabel")}
</p>
{GROUP_FIELDS.map((field) => (
<div
key={field}
className="flex min-h-11 items-center justify-between gap-3"
data-testid={`coach-nudge-group-${field}`}
>
<div className="min-w-0">
<p className="text-sm">{t(GROUP_I18N[field].label)}</p>
<p className="text-muted-foreground text-xs">
{t(GROUP_I18N[field].desc)}
</p>
</div>
<Switch
checked={resolved[field]}
onCheckedChange={(next) => patchCoach({ [field]: next }, null)}
disabled={!isAuthenticated || saving}
aria-label={t(GROUP_I18N[field].label)}
/>
</div>
))}
<div className="flex min-h-11 items-center gap-3">
<label
htmlFor="coach-nudge-frequency"
className="text-sm font-medium"
>
{t("notifications.coachNudge.frequencyLabel")}
</label>
<NativeSelect
id="coach-nudge-frequency"
className="w-auto"
value={resolved.nudgeFrequency}
disabled={!isAuthenticated || saving}
onChange={(e) =>
patchCoach(
{
nudgeFrequency: e.target.value as "weekly" | "biweekly",
},
null,
)
}
aria-label={t("notifications.coachNudge.frequencyAria")}
>
<option value="weekly">
{t("notifications.coachNudge.frequencyWeekly")}
</option>
<option value="biweekly">
{t("notifications.coachNudge.frequencyBiweekly")}
</option>
</NativeSelect>
</div>
</div>
)}
{msg && (
<p
role="status"
+41 -131
View File
@@ -19,17 +19,10 @@
* contradict each other.
*/
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { Loader2, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -41,14 +34,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -76,15 +66,6 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"anthropometrics",
];
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export interface CoachPrefsSectionProps {
isAuthenticated: boolean;
}
@@ -122,36 +103,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
}));
}
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const isLoading = isAuthenticated && !persisted;
return (
<Card data-slot="coach-prefs-section">
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t("insights.coach.settingsTitle")}
</CardTitle>
<CardDescription>
{t("insights.coach.settingsDescription")}
</CardDescription>
</CardHeader>
<div
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="coach-prefs-section"
>
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("insights.coach.settingsTitle")}
description={t("insights.coach.settingsDescription")}
/>
{isLoading ? (
<CardContent
<div
data-slot="coach-prefs-skeleton"
className="flex flex-col gap-5"
className="mt-4 flex flex-col gap-5 pl-7"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
@@ -166,9 +133,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
</CardContent>
</div>
) : (
<CardContent className="flex flex-col gap-5">
<div className="mt-4 flex flex-col gap-5 pl-7">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
@@ -181,7 +148,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -236,84 +206,24 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
{/* Default analysis window */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
{/* Data clusters + analysis window live on ONE owner: the
sources rail inside the chat ("What I draw on"). This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
them. */}
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-[11px] leading-relaxed"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/insights/coach"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Clustered, opt-in data sources */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">
@@ -386,9 +296,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
})}
</ul>
</div>
</CardContent>
</div>
)}
<CardFooter className="justify-end">
<div className="mt-4 flex justify-end pl-7">
<Button
type="button"
size="sm"
@@ -404,7 +314,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
) : null}
{t("insights.coach.settingsSave")}
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
@@ -59,7 +59,7 @@ export function CycleTrackingCard({
const enabled = data?.cycleTrackingEnabled ?? false;
return (
<div className="bg-card rounded-lg border p-4 sm:p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={CalendarHeart}
title={t("settings.cycleTracking.title")}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more