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