mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
1 parent
a4b3d2fcd0
commit
40f7196c20
115 files changed
+3014
-1084
No files matched your search
+72
-3
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
+32
-2
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
@@ -104,6 +105,7 @@ export function AdminSectionRenderer({
|
||||
subtitle={t("admin.section.assistant.subtitle")}
|
||||
>
|
||||
<AssistantSection />
|
||||
<AiServerKeySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "coach-feedback":
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v1.16.6 — admin CRUD contract for the operator-wide AI key
|
||||
* (`/api/admin/ai-settings`). The endpoint predates its UI; the new
|
||||
* admin "AI server key" card now drives it, so the contract gets
|
||||
* pinned: admin-only access, encrypted storage, key removal, the
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/ai-settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/ai-settings", () => {
|
||||
it("propagates the admin gate (non-admin cannot read)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(GET()).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it("returns defaults + hasKey=false when nothing is configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns hasKey=true with a masked preview when a key is stored", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc(sk-test-12cd)",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...12cd");
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
// The plaintext key never leaks into the envelope.
|
||||
expect(JSON.stringify(body)).not.toContain("sk-test-12cd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("encrypts and stores a new key, audit-logged", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab");
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" },
|
||||
}),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...89ab");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.ai-settings.update",
|
||||
expect.objectContaining({ userId: "admin-1" }),
|
||||
);
|
||||
// The audit detail records THAT the key changed, never the key.
|
||||
const details = vi.mocked(auditLog).mock.calls[0][1] as {
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab");
|
||||
});
|
||||
|
||||
it("clears the key when apiKey is empty", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ update: { adminAiKeyEncrypted: null } }),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(false);
|
||||
expect(body.data.keyPreview).toBeNull();
|
||||
});
|
||||
|
||||
it("updates model and baseUrl for an allowlisted https host", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(
|
||||
jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
expect(body.data.baseUrl).toBe("https://openrouter.ai/v1");
|
||||
});
|
||||
|
||||
it("rejects a plain-http base URL", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
});
|
||||
|
||||
it("rejects a base URL host outside the allowlist", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty model", async () => {
|
||||
await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a body with no recognised fields", async () => {
|
||||
await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when the admin surface is rate-limited", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
|
||||
const {
|
||||
conversationId,
|
||||
message,
|
||||
locale: bodyLocale,
|
||||
scope,
|
||||
guidedQuestion,
|
||||
} = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling layered in front of the daily budget
|
||||
// gate. The budget catches the cost dimension; this catches the
|
||||
@@ -206,7 +212,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
|
||||
// ── Refusal short-circuit ────────────────────────────────────
|
||||
const refusal = detectRefusal({ message, locale });
|
||||
// v1.16.6 — the guided-flow question rides the prompt too, so it
|
||||
// runs through the same regex bank as the message. The questions
|
||||
// are server-derived in the honest case; this guards the dishonest
|
||||
// one (a crafted client using the field as an unchecked channel).
|
||||
const refusal = detectRefusal({
|
||||
message: guidedQuestion ? `${guidedQuestion}\n${message}` : message,
|
||||
locale,
|
||||
});
|
||||
if (refusal.refuse && refusal.message) {
|
||||
annotate({
|
||||
action: { name: "insights.coach.refused" },
|
||||
@@ -358,9 +371,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const transcript = window
|
||||
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
|
||||
.join("\n\n");
|
||||
// v1.16.6 — guided clarifying-questions flow: the question the user
|
||||
// is answering exists only as a client-side bubble, so hand it to
|
||||
// the model as delimited context. The reaction should read as a
|
||||
// natural reply to the answer, not as a re-ask.
|
||||
const guidedBlock = guidedQuestion
|
||||
? `\nGUIDED QUESTION (user-provided context)
|
||||
The user's message answers this clarifying question from their self-context questionnaire:
|
||||
"""${guidedQuestion}"""
|
||||
React briefly and personally to the answer; do not repeat the question and do not ask it again.
|
||||
`
|
||||
: "";
|
||||
const userPrompt = `SNAPSHOT
|
||||
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
|
||||
|
||||
${guidedBlock}
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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<Promise<unknown>>)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
// Ownership guard + GET both read `medication.findUnique`; the guard
|
||||
// selects `{ id, userId }`, the GET selects `{ createdAt }` — one
|
||||
// superset row satisfies both.
|
||||
@@ -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<string, unknown> = {}) {
|
||||
vi.mocked(
|
||||
prisma.medicationScheduleRevision.findUnique,
|
||||
).mockResolvedValue({
|
||||
id: "rev-t",
|
||||
medicationId: "m1",
|
||||
source: "MANUAL",
|
||||
supersededByRevisionId: null,
|
||||
validUntil: new Date("2025-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("404s when the revision belongs to another medication", async () => {
|
||||
mockTarget({ medicationId: "other-med" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("409s when the era has already been corrected", async () => {
|
||||
mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when the corrected era overlaps another active era", async () => {
|
||||
mockTarget();
|
||||
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
|
||||
{
|
||||
validFrom: new Date("2025-06-01T00:00:00Z"),
|
||||
validUntil: new Date("2025-08-01T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/overlap/i);
|
||||
});
|
||||
|
||||
it("422s when the correction extends past the live boundary", async () => {
|
||||
// ARCHIVED era ending 2025-06-01, no other revisions; the live
|
||||
// ceiling is max(own recorded end, createdAt 2026-01-10) — a
|
||||
// correction reaching past it would swallow tracked live history.
|
||||
mockTarget({ source: "ARCHIVED" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", {
|
||||
...PATCH_BODY,
|
||||
validUntil: "2026-02-01T00:00:00.000Z",
|
||||
}),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/current plan/i);
|
||||
});
|
||||
|
||||
it("updates a MANUAL era in place with the deduped snapshot", async () => {
|
||||
mockTarget();
|
||||
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue({
|
||||
id: "rev-t",
|
||||
validFrom: new Date(PATCH_BODY.validFrom),
|
||||
validUntil: new Date(PATCH_BODY.validUntil),
|
||||
source: "MANUAL",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.id).toBe("rev-t");
|
||||
expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
|
||||
const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update)
|
||||
.mock.calls[0][0] as unknown as {
|
||||
where: { id: string };
|
||||
data: { payload: Array<{ timesOfDay: string[]; rrule: string }> };
|
||||
};
|
||||
expect(updateArgs.where.id).toBe("rev-t");
|
||||
expect(updateArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
|
||||
// In-place — no superseding row is minted.
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
// Check-then-write ran inside the locked transaction.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("corrects an ARCHIVED era via a superseding MANUAL revision", async () => {
|
||||
mockTarget({ source: "ARCHIVED" });
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
|
||||
id: "rev-fix",
|
||||
validFrom: new Date(PATCH_BODY.validFrom),
|
||||
validUntil: new Date(PATCH_BODY.validUntil),
|
||||
source: "MANUAL",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
// The response carries the correction, which now takes the
|
||||
// original's place in every era read.
|
||||
expect(json.data.id).toBe("rev-fix");
|
||||
expect(json.data.source).toBe("MANUAL");
|
||||
|
||||
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
|
||||
.mock.calls[0][0] as unknown as {
|
||||
data: { source: string; payload: Array<{ timesOfDay: string[] }> };
|
||||
};
|
||||
expect(createArgs.data.source).toBe("MANUAL");
|
||||
expect(createArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
// The ARCHIVED original is parked behind the correction, not
|
||||
// touched in its recorded content.
|
||||
expect(prisma.medicationScheduleRevision.update).toHaveBeenCalledWith({
|
||||
where: { id: "rev-t" },
|
||||
data: { supersededByRevisionId: "rev-fix" },
|
||||
});
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
orderedVisibleSectionIds,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -221,6 +222,7 @@ interface ComprehensiveData {
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
@@ -340,8 +342,12 @@ export default function InsightsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Gated on `mounted`: the auth query can resolve before this
|
||||
// boundary hydrates, and a greeting that personalises during the
|
||||
// hydration render disagrees with the name-less SSR text — React
|
||||
// #418. See `useMounted`.
|
||||
const heroGreetingName =
|
||||
user?.username?.trim() && user.username.trim().length > 0
|
||||
mounted && user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
});
|
||||
|
||||
it("folds Telefónica + O2 variants down to 'O2'", () => {
|
||||
expect(
|
||||
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
|
||||
).toBe("O2");
|
||||
expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2");
|
||||
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
|
||||
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
|
||||
});
|
||||
@@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
it("returns the raw organisation string for unknown carriers", () => {
|
||||
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
|
||||
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe(
|
||||
"Hetzner Online GmbH",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => {
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
|
||||
});
|
||||
|
||||
it("stacks header rows on mobile so filter pills don't collide with title", () => {
|
||||
it("wraps the filter pills below the title on narrow viewports", () => {
|
||||
const html = render();
|
||||
// Header carries `flex-col` at base + `sm:flex-row` at small+.
|
||||
expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/);
|
||||
// The shared SettingsCardHeader title row wraps, so the status slot
|
||||
// (filter pills) reflows under the title instead of colliding with it.
|
||||
expect(html).toMatch(
|
||||
/class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
@@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
|
||||
* and Web-Push VAPID cards all surface the same state; one component
|
||||
* keeps the tint and copy in lockstep. */
|
||||
export function ConfiguredBadge() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -66,7 +67,7 @@ export function AiQualitySection() {
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.aiQuality.loading")}
|
||||
@@ -90,14 +91,12 @@ export function AiQualitySection() {
|
||||
|
||||
if (!summary || summary.buckets.length === 0) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-3 pl-7 text-sm">
|
||||
{t("admin.aiQuality.empty")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,23 +104,21 @@ export function AiQualitySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
status={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="ai-quality-table">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
* v1.16.6 — operator panel for the server-wide AI key.
|
||||
*
|
||||
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
|
||||
* existed since the `admin-openai` chain fallback shipped, but it never had
|
||||
* an operator UI — configuring the shared key took a manual API call. This
|
||||
* card is its home, beside the assistant feature flags.
|
||||
*
|
||||
* Semantics worth keeping visible to the operator:
|
||||
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
|
||||
* key sits at the END of the provider chain as the shared fallback.
|
||||
* - Egress over the server key is consent-gated per user
|
||||
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
|
||||
* key here never silently forwards anyone's health data.
|
||||
* - The base URL is locked server-side to HTTPS + a hostname allowlist
|
||||
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
|
||||
*/
|
||||
|
||||
interface AiServerKeyResponse {
|
||||
hasKey: boolean;
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
|
||||
});
|
||||
|
||||
const [keyDraft, setKeyDraft] = useState("");
|
||||
const [modelDraft, setModelDraft] = useState<string | null>(null);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
|
||||
|
||||
const modelValue = modelDraft ?? settings?.model ?? "";
|
||||
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
|
||||
const configured = settings?.hasKey ?? false;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
|
||||
onSuccess: () => {
|
||||
setKeyDraft("");
|
||||
setModelDraft(null);
|
||||
setBaseUrlDraft(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
});
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelValue,
|
||||
baseUrl: baseUrlValue,
|
||||
};
|
||||
if (keyDraft.trim().length > 0) {
|
||||
payload.apiKey = keyDraft.trim();
|
||||
}
|
||||
save.mutate(payload);
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
save.mutate({ apiKey: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="admin-ai-server-key"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.aiServerKey.title")}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.description")}</p>
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
</>
|
||||
}
|
||||
status={
|
||||
<Badge variant={configured ? "default" : "outline"}>
|
||||
{configured
|
||||
? t("admin.aiServerKey.statusConfigured")
|
||||
: t("admin.aiServerKey.statusNotConfigured")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-input">
|
||||
{t("admin.aiServerKey.keyLabel")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-ai-server-key-input"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
placeholder={
|
||||
configured && settings?.keyPreview
|
||||
? t("admin.aiServerKey.keyPlaceholderSet", {
|
||||
preview: settings.keyPreview,
|
||||
})
|
||||
: t("admin.aiServerKey.keyPlaceholder")
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-model">
|
||||
{t("admin.aiServerKey.modelLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-model"
|
||||
value={modelValue}
|
||||
onChange={(e) => setModelDraft(e.target.value)}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-base-url">
|
||||
{t("admin.aiServerKey.baseUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-base-url"
|
||||
value={baseUrlValue}
|
||||
onChange={(e) => setBaseUrlDraft(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-save"
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.aiServerKey.save")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-remove"
|
||||
>
|
||||
{t("admin.aiServerKey.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
@@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("admin.apiTokens")}
|
||||
description={t("admin.apiTokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WideEvent, LogLevel } from "@/lib/logging/types";
|
||||
@@ -104,7 +105,9 @@ export function AppLogPreviewSection() {
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
|
||||
queryFn: async () => {
|
||||
return apiGet<AppLogsResponse>(`/api/admin/app-logs?${params.toString()}`);
|
||||
return apiGet<AppLogsResponse>(
|
||||
`/api/admin/app-logs?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
@@ -112,31 +115,25 @@ export function AppLogPreviewSection() {
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.section.app-logs.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.app-logs.processNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
description={t("admin.section.app-logs.processNote")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
@@ -49,7 +50,9 @@ function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AssistantFlagsResponse>("/api/admin/settings/assistant-flags");
|
||||
return apiGet<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -61,13 +64,13 @@ function useUpdateAssistantFlags() {
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>("/api/admin/settings/assistant-flags", patch);
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
patch,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(
|
||||
queryKeys.adminAssistantFlags(),
|
||||
data,
|
||||
);
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), data);
|
||||
// Bust the runtime `/api/feature-flags` cache so the operator
|
||||
// sees the toggled surface react within the same session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
@@ -96,73 +99,72 @@ export function AssistantSection() {
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground pl-7 text-sm">
|
||||
{t("admin.assistant.description")}
|
||||
</p>
|
||||
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.assistant.title")}
|
||||
description={t("admin.assistant.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
@@ -331,34 +332,34 @@ export function BackupsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.section.backups.title")}
|
||||
</div>
|
||||
{data && (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.backups.title")}
|
||||
titleAccessory={
|
||||
data ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.section.backups.description")}{" "}
|
||||
{/* External docs link — Phase E will publish the matching page on
|
||||
the docs site. `noopener noreferrer` because this leaves the
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function BugReportSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -45,27 +50,15 @@ export function BugReportSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.bugReportGithub")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
description={t("admin.bugReportGithubDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.bugReportEnabledLabel")}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -69,25 +70,23 @@ export function CoachFeedbackSection() {
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
{hasData && summary && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.coachFeedback.title")}
|
||||
status={
|
||||
hasData && summary ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DangerZoneSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-6">
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
|
||||
already names the section, so the card-level title was a
|
||||
duplicate of the page title. The destructive icon stays so
|
||||
|
||||
@@ -54,7 +54,9 @@ export function FeedbackInboxSection() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
return apiGet<FeedbackListResponse>(`/api/admin/feedback?status=${activeStatus}&limit=100`);
|
||||
return apiGet<FeedbackListResponse>(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ export function FeedbackInboxSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
|
||||
`/admin/feedback`, so the page header already provides the
|
||||
title + description — the card-level repetition was just
|
||||
@@ -287,7 +289,9 @@ function FeedbackDetailDialog({
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`);
|
||||
return apiPost<{ issueUrl: string }>(
|
||||
`/api/admin/feedback/${item.id}/github`,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
@@ -25,12 +26,13 @@ export function GeneralSettingsSection() {
|
||||
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t("admin.appSettings")}
|
||||
description={t("admin.appSettingsDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -68,25 +69,15 @@ export function GlitchtipSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.glitchtipDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.glitchtipTitle")}
|
||||
description={t("admin.glitchtipDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
|
||||
@@ -314,12 +314,15 @@ export function InviteTokensSection() {
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<Ticket
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 id="admin-invites-title" className="text-lg font-semibold">
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-sm">
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
|
||||
@@ -559,79 +562,79 @@ export function InviteTokensSection() {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toCSV } from "@/lib/export";
|
||||
@@ -131,7 +132,9 @@ export function LoginOverviewSection() {
|
||||
if (filter === "failed") {
|
||||
params.set("action", "auth.login.failed");
|
||||
}
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?${params.toString()}`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,11 +196,12 @@ export function LoginOverviewSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
title={t("admin.loginOverview")}
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
@@ -38,7 +39,9 @@ export function RecentAuditPreview() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
|
||||
);
|
||||
},
|
||||
// Avoid hitting the DB on every page focus; the overview is the
|
||||
// type of page admins keep open and re-render frequently.
|
||||
@@ -52,26 +55,22 @@ export function RecentAuditPreview() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-audit-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-audit-heading"
|
||||
className="text-lg font-semibold"
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
titleId="admin-overview-audit-heading"
|
||||
title={t("admin.overview.auditTitle")}
|
||||
status={
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -91,21 +92,17 @@ export function RemindersSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.medicationRemindersDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("admin.medicationReminders")}
|
||||
description={t("admin.medicationRemindersDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -10,15 +11,13 @@ export function ServicesSection() {
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.servicesGlobalDescription")}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Globe}
|
||||
title={t("admin.servicesGlobal")}
|
||||
description={t("admin.servicesGlobalDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -51,13 +52,14 @@ export function SystemStatusSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HostMetricsChart />
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
title={t("admin.systemStatus")}
|
||||
description={t("admin.systemStatusDescription")}
|
||||
/>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -34,20 +35,16 @@ export function SystemStatusSummary() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-snapshot-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-snapshot-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.snapshotTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
titleId="admin-overview-snapshot-heading"
|
||||
title={t("admin.overview.snapshotTitle")}
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -71,25 +72,15 @@ export function UmamiSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.umamiDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("admin.umamiTitle")}
|
||||
description={t("admin.umamiDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
@@ -220,18 +221,12 @@ export function UserManagementSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* Header: stacks vertically on mobile so the filter pills get
|
||||
their own row instead of overlapping the title. The audit
|
||||
flagged this as CRITICAL #2 — at 393px the filter row was
|
||||
colliding with the count Badge and the user-count digit was
|
||||
floating into the title's gap. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.userManagement")}
|
||||
</div>
|
||||
{filteredUsers && (
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("admin.userManagement")}
|
||||
description={t("admin.userManagementDescription")}
|
||||
titleAccessory={
|
||||
filteredUsers ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredUsers.length}
|
||||
{filter !== "all" &&
|
||||
@@ -242,24 +237,25 @@ export function UserManagementSection() {
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Filters mirror the Settings-style horizontal pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{filteredUsers ? (
|
||||
filteredUsers.length === 0 ? (
|
||||
|
||||
@@ -16,9 +16,16 @@
|
||||
* (`/admin`), which gates on `requireAdmin()`.
|
||||
*/
|
||||
|
||||
import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitCommit,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -77,42 +84,39 @@ export function VersionTileSection() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-version-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-version-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.versionTileTitle")}
|
||||
</h2>
|
||||
<SettingsCardHeader
|
||||
icon={Tag}
|
||||
titleId="admin-overview-version-heading"
|
||||
title={t("admin.overview.versionTileTitle")}
|
||||
status={
|
||||
newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green ml-auto inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.versionTileVersion")}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function WebPushVapidSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -48,27 +53,15 @@ export function WebPushVapidSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.webPushVapidTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.webPushVapidDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("admin.webPushVapidTitle")}
|
||||
description={t("admin.webPushVapidDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
|
||||
@@ -203,6 +203,58 @@ describe("guidedReducer — adoption settling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("guidedReducer — reaction-then-adopt sequencing (v1.16.6)", () => {
|
||||
// With a provider, the turn streams a Coach reaction and the flow
|
||||
// waits in `answered` until the adopt offer settles; the settle of
|
||||
// the CURRENT outcome is what advances the sequence.
|
||||
it("ADOPTION_SETTLED for the current outcome advances to the next question", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
|
||||
]);
|
||||
if (state.phase !== "asking") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
expect(state.outcomes[0].adoption).toBe("adopted");
|
||||
});
|
||||
|
||||
it("ADOPTION_SETTLED on the last answer lands on the summary", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: ["only one"] },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "declined" },
|
||||
]);
|
||||
expect(state.phase).toBe("summary");
|
||||
if (state.phase !== "summary") throw new Error("unreachable");
|
||||
expect(state.outcomes[0].adoption).toBe("declined");
|
||||
});
|
||||
|
||||
it("a late settle for an older outcome does not advance", () => {
|
||||
// Silent (provider-less) flow already advanced via TURN_COMPLETE;
|
||||
// question 2 is answered and streaming when outcome 0 settles.
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "TURN_COMPLETE" },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Sleep." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
|
||||
]);
|
||||
if (state.phase !== "answered") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
expect(state.outcomes[0].adoption).toBe("adopted");
|
||||
});
|
||||
|
||||
it("the silent flow still advances on TURN_COMPLETE alone", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "TURN_COMPLETE" },
|
||||
]);
|
||||
if (state.phase !== "asking") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveThreadItems", () => {
|
||||
it("idle and done contribute nothing", () => {
|
||||
expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]);
|
||||
|
||||
@@ -204,9 +204,12 @@ export function CoachConversation({
|
||||
dismissQuestions.mutate(guidedQuestion);
|
||||
}
|
||||
setInputValue("");
|
||||
await send.send({
|
||||
// v1.16.6 — hand the question to the turn so the Coach reaction is
|
||||
// contextual (the question bubble itself is never persisted).
|
||||
const resolvedId = await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
guidedQuestion: guidedQuestion ?? undefined,
|
||||
});
|
||||
if (guidedQuestion !== null && guidedIndex !== null) {
|
||||
setPendingAdopt({
|
||||
@@ -214,9 +217,15 @@ export function CoachConversation({
|
||||
answer: trimmed,
|
||||
guidedIndex,
|
||||
});
|
||||
// The assistant turn is done — advance to the next question (or
|
||||
// the closing summary).
|
||||
dispatchGuided({ type: "TURN_COMPLETE" });
|
||||
// v1.16.6 — sequence per answer: answer → Coach reaction
|
||||
// (streamed above) → adopt offer → next question. A resolved id
|
||||
// means the reaction landed; the machine then advances when the
|
||||
// adopt offer settles (ADOPTION_SETTLED). A null id is the
|
||||
// provider-less / errored turn — no reaction is coming, so keep
|
||||
// the original silent flow and advance immediately.
|
||||
if (resolvedId === null) {
|
||||
dispatchGuided({ type: "TURN_COMPLETE" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
*
|
||||
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
|
||||
* │ │ ▲ │
|
||||
* LATER SKIP└──────TURN_COMPLETE────────┘
|
||||
* │ │ (next question, or…)
|
||||
* ▼ ├──EXIT/SKIP-on-last──▶ summary (answers exist)
|
||||
* done ◀────────────┴──────────────────────▶ done (none answered)
|
||||
* LATER SKIP└── TURN_COMPLETE ──────────┤
|
||||
* │ │ (provider-less: advance │
|
||||
* │ │ right after the send) │
|
||||
* │ │ ▲ │
|
||||
* │ └── ADOPTION_SETTLED ─────────┘
|
||||
* │ │ (current outcome: Coach reaction has
|
||||
* │ │ streamed, the adopt offer settled —
|
||||
* ▼ │ NOW the next question may appear)
|
||||
* done ◀────────────┴──EXIT/SKIP-on-last──▶ summary (answers exist)
|
||||
* ──▶ done (none answered)
|
||||
*
|
||||
* Server contract stays additive-free: answering dismisses that one
|
||||
* question (existing DELETE), "skip" / "later" leave questions pending
|
||||
@@ -140,11 +146,7 @@ export function guidedReducer(
|
||||
|
||||
case "TURN_COMPLETE": {
|
||||
if (state.phase !== "answered") return state;
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return { ...state, phase: "asking", index: next };
|
||||
}
|
||||
return finish(state);
|
||||
return advance(state);
|
||||
}
|
||||
|
||||
case "SKIP": {
|
||||
@@ -174,11 +176,38 @@ export function guidedReducer(
|
||||
? { ...o, adoption: event.adoption }
|
||||
: o,
|
||||
);
|
||||
// v1.16.6 — when a provider reacted to the answer the sequence
|
||||
// waits in `answered` until the adopt offer settles, so the
|
||||
// transcript reads answer → Coach reaction → adopt outcome →
|
||||
// next question. Settling the CURRENT outcome is that signal;
|
||||
// a late settle for an older outcome (provider-less flow, where
|
||||
// TURN_COMPLETE already advanced) only updates the tally.
|
||||
if (state.phase === "answered" && event.index === state.index) {
|
||||
return advance({ ...state, outcomes });
|
||||
}
|
||||
return { ...state, outcomes };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move past the current question: next one, or close the sequence. */
|
||||
function advance(state: {
|
||||
questions: string[];
|
||||
index: number;
|
||||
outcomes: GuidedOutcome[];
|
||||
}): GuidedState {
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return {
|
||||
phase: "asking",
|
||||
questions: state.questions,
|
||||
index: next,
|
||||
outcomes: state.outcomes,
|
||||
};
|
||||
}
|
||||
return finish(state);
|
||||
}
|
||||
|
||||
/** Count of answers actually written into the self-context. */
|
||||
export function countAdopted(outcomes: GuidedOutcome[]): number {
|
||||
return outcomes.filter((o) => o.adoption === "adopted").length;
|
||||
|
||||
@@ -236,6 +236,12 @@ export interface SendCoachMessageParams {
|
||||
* back to all-source last30days, matching the legacy behaviour.
|
||||
*/
|
||||
scope?: CoachScope;
|
||||
/**
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question
|
||||
* this message answers. Server-side prompt context only; never
|
||||
* persisted with the message.
|
||||
*/
|
||||
guidedQuestion?: string;
|
||||
}
|
||||
|
||||
export interface UseSendCoachMessageOptions {
|
||||
@@ -286,7 +292,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (params: SendCoachMessageParams): Promise<void> => {
|
||||
async (
|
||||
params: SendCoachMessageParams,
|
||||
): Promise<string | null> => {
|
||||
// Cancel any prior in-flight request.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -322,7 +330,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// apiFetchRaw: the chat POST streams SSE — the envelope helpers
|
||||
@@ -341,11 +349,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
prefill: params.prefill,
|
||||
locale: params.locale,
|
||||
scope: params.scope,
|
||||
guidedQuestion: params.guidedQuestion,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
if ((err as Error).name === "AbortError") return null;
|
||||
setStreaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
@@ -353,7 +362,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
@@ -364,7 +373,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: `coach.http.${response.status}`,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Even on `!response.ok`, the route may have emitted a structured
|
||||
@@ -405,7 +414,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
// the bubble left the error standing next to an empty thread.
|
||||
// The content-equality dedupe in <MessageThread> still
|
||||
// suppresses the optimistic copy if a persisted twin lands.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -495,6 +504,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
if (resolvedConversationId || !lastError) {
|
||||
setOptimisticUser(null);
|
||||
}
|
||||
return resolvedConversationId;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
@@ -470,3 +470,74 @@ describe("medication card body — shared shell + dose-state presentation", () =
|
||||
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compliance bars — fixed geometry (v1.16.6)", () => {
|
||||
/**
|
||||
* The two percentage rows must keep an IDENTICAL, fixed geometry no
|
||||
* matter the rates, labels, or any status colouring around them: each
|
||||
* label line is pinned to one text line (`h-5` + `truncate`) and each
|
||||
* bar to `h-2`. A regression here lets a wrapping label or a
|
||||
* status-dependent caption make the two bars sit at unequal heights
|
||||
* with stray space below (the v1.16.4 card report).
|
||||
*/
|
||||
function geometrySignature(html: string): {
|
||||
labelRows: number;
|
||||
bars: number;
|
||||
truncated: number;
|
||||
} {
|
||||
return {
|
||||
labelRows: (html.match(/flex h-5 items-center justify-between/g) ?? [])
|
||||
.length,
|
||||
bars: (html.match(/data-slot="progress"/g) ?? []).length,
|
||||
truncated: (html.match(/truncate/g) ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
it("pins both rows to the same fixed-height markup regardless of rates", () => {
|
||||
const low = render(
|
||||
<MedicationComplianceBars rate7={0} rate30={13} streak={0} />,
|
||||
);
|
||||
const high = render(
|
||||
<MedicationComplianceBars rate7={100} rate30={100} streak={9} />,
|
||||
);
|
||||
const wide = render(
|
||||
<MedicationComplianceBars
|
||||
rate7={50}
|
||||
rate30={50}
|
||||
streak={0}
|
||||
shortDays={90}
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const html of [low, high, wide]) {
|
||||
const sig = geometrySignature(html);
|
||||
expect(sig.labelRows).toBe(2);
|
||||
expect(sig.bars).toBe(2);
|
||||
expect(sig.truncated).toBe(2);
|
||||
// Both bars carry the same fixed track height.
|
||||
expect(html.match(/data-slot="progress"[^>]*h-2/g) ?? []).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("rate / streak changes alter text only, never the geometry classes", () => {
|
||||
const a = render(
|
||||
<MedicationComplianceBars rate7={0} rate30={0} streak={0} />,
|
||||
);
|
||||
const b = render(
|
||||
<MedicationComplianceBars rate7={100} rate30={42} streak={0} />,
|
||||
);
|
||||
// Strip text nodes; the remaining tag/class skeleton must be identical
|
||||
// between a 0% and a 100% card (transform style varies with the value
|
||||
// by design, so it is normalised too).
|
||||
const skeleton = (html: string) =>
|
||||
html
|
||||
.replace(/>[^<]+</g, "><")
|
||||
.replace(/transform:[^;"]+/g, "transform:X")
|
||||
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
|
||||
.replace(/data-state="[^"]*"/g, 'data-state="S"')
|
||||
.replace(/data-value="[^"]*"/g, 'data-value="V"')
|
||||
.replace(/data-max="[^"]*"/g, 'data-max="M"');
|
||||
expect(skeleton(a)).toBe(skeleton(b));
|
||||
});
|
||||
});
|
||||
@@ -61,27 +61,35 @@ export function MedicationComplianceBars({
|
||||
const shortPct = fmt.number(Math.round(rate7));
|
||||
const longPct = fmt.number(Math.round(rate30));
|
||||
|
||||
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
|
||||
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
|
||||
// so neither a long locale label wrapping, nor the `?` trigger, nor any
|
||||
// status-coloured caption can make the two rows differ in height. Status
|
||||
// is colour / text only — never geometry; the two bars on a card (and
|
||||
// across a grid row) always align.
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
{shortLabel}
|
||||
<div className="flex h-5 items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{shortLabel}</span>
|
||||
{/* One `?` for the whole block — both rows measure the same
|
||||
thing over different windows, so a second trigger on the
|
||||
long row would only add noise. */}
|
||||
<ComplianceInfoTip />
|
||||
</span>
|
||||
<span className="font-medium">{shortPct}%</span>
|
||||
<span className="shrink-0 font-medium">{shortPct}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{longPct}%</span>
|
||||
<div className="flex h-5 items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{longLabel}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">{longPct}%</span>
|
||||
</div>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
@@ -121,14 +129,14 @@ export function MedicationComplianceSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2.5" aria-hidden>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
|
||||
@@ -318,6 +318,16 @@ export function Glp1MedicationCard({
|
||||
active: medication.active,
|
||||
lastTakenAt: medication.lastTakenAt,
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
// v1.16.6 — gate the pill on the server display-due so a rolling
|
||||
// cadence whose next injection is days away can never paint an overdue
|
||||
// pill today. `undefined` (older payloads / fixtures) keeps legacy
|
||||
// behaviour; `next` is already the parsed-and-validated `nextDueAt`.
|
||||
nextDue:
|
||||
medication.nextDueAt === undefined
|
||||
? undefined
|
||||
: next
|
||||
? { at: next.date, overdue: medication.nextDueOverdue === true }
|
||||
: null,
|
||||
});
|
||||
|
||||
// v1.12.3 — slot instant of the injection this card is showing (the
|
||||
|
||||
@@ -251,6 +251,15 @@ export function MedicationCard({
|
||||
active: medication.active,
|
||||
lastTakenAt: medication.lastTakenAt,
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
// v1.16.6 — gate the pill on the server display-due so a rolling
|
||||
// cadence whose next dose is tomorrow can never paint an overdue pill
|
||||
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
|
||||
nextDue:
|
||||
medication.nextDueAt === undefined
|
||||
? undefined
|
||||
: nextAt !== undefined
|
||||
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
|
||||
: null,
|
||||
});
|
||||
|
||||
// v1.12.3 — the slot instant of the dose this card is currently showing
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* the chip + delete affordance, ARCHIVED eras get neither.
|
||||
* 4. No revisions: no toggle, the add-era CTA still renders.
|
||||
* 5. `eraTimes` dedupes/sorts across entries and drops non-arrays.
|
||||
* 6. v1.16.6: every era row (MANUAL and ARCHIVED) carries the edit
|
||||
* pencil; only MANUAL rows carry delete.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -130,9 +132,13 @@ describe("ScheduleHistoryTimeline", () => {
|
||||
expect(html).toContain("Manuell ergänzt");
|
||||
expect(html).toContain("Ära löschen");
|
||||
// Exactly ONE manual chip + ONE delete button — the archived era
|
||||
// renders read-only.
|
||||
// renders without delete.
|
||||
expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1);
|
||||
expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1);
|
||||
// BOTH eras carry the edit pencil: a MANUAL era edits in place, an
|
||||
// ARCHIVED era is corrected via a superseding revision.
|
||||
expect(html.match(/zeitplan-history-edit/g)).toHaveLength(2);
|
||||
expect(html).toContain("Ära bearbeiten");
|
||||
expect(html).toContain("Frühere Ären ausblenden");
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
* - MANUAL eras carry a tinted chip and a delete affordance
|
||||
* (confirmed via AlertDialog); write-path archives are immutable
|
||||
* and render without one.
|
||||
* - v1.16.6: every era row carries an edit pencil. The dialog opens
|
||||
* prefilled like the add flow; a MANUAL era PATCHes in place, an
|
||||
* ARCHIVED era shows a quiet hint that the change corrects the
|
||||
* recorded history (the server mints a superseding MANUAL row and
|
||||
* keeps the original as the audit record).
|
||||
*
|
||||
* The "Bis" date is the day the NEXT plan took over (exclusive bound) —
|
||||
* the dialog copy says so, and adjacency with the live plan is the
|
||||
@@ -25,7 +30,14 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, History, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
History,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -50,7 +62,7 @@ import {
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -103,6 +115,9 @@ export function ScheduleHistoryTimeline({
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<ScheduleRevisionRow | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduleRevisionRow | null>(
|
||||
null,
|
||||
);
|
||||
@@ -206,22 +221,37 @@ export function ScheduleHistoryTimeline({
|
||||
{fmt.date(revision.validUntil)}
|
||||
</p>
|
||||
</div>
|
||||
{revision.source === "MANUAL" && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-dracula-red size-8 shrink-0"
|
||||
className="text-muted-foreground hover:text-foreground size-8"
|
||||
aria-label={t(
|
||||
"medications.detail.zeitplan.history.deleteAria",
|
||||
"medications.detail.zeitplan.history.editAria",
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(revision)}
|
||||
data-slot="zeitplan-history-delete"
|
||||
onClick={() => setEditTarget(revision)}
|
||||
data-slot="zeitplan-history-edit"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{revision.source === "MANUAL" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-dracula-red size-8"
|
||||
aria-label={t(
|
||||
"medications.detail.zeitplan.history.deleteAria",
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(revision)}
|
||||
data-slot="zeitplan-history-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRow>
|
||||
);
|
||||
@@ -266,11 +296,18 @@ export function ScheduleHistoryTimeline({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddEraDialog
|
||||
<EraDialog
|
||||
key={editTarget?.id ?? "add"}
|
||||
medicationId={medicationId}
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onCreated={() => setExpanded(true)}
|
||||
target={editTarget}
|
||||
open={addOpen || editTarget !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setAddOpen(false);
|
||||
setEditTarget(null);
|
||||
}
|
||||
}}
|
||||
onSaved={() => setExpanded(true)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
@@ -343,37 +380,58 @@ function TimelineRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** ISO instant → the local calendar date a `DateInput` understands. */
|
||||
function toLocalDateInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Frühere Ära ergänzen" — collects daily dose times + the Von/Bis date
|
||||
* pair and POSTs a MANUAL schedule revision. Client-side guards mirror
|
||||
* the server validation (times present, Von before Bis); the server
|
||||
* stays authoritative for overlap / before-current-plan and its message
|
||||
* surfaces through the error toast.
|
||||
* "Frühere Ära ergänzen" / "Ära bearbeiten" — collects daily dose times
|
||||
* + the Von/Bis date pair. Without a `target` it POSTs a MANUAL
|
||||
* revision; with one it PATCHes the era (a MANUAL era updates in place,
|
||||
* an ARCHIVED era gets a superseding correction — the dialog shows a
|
||||
* quiet hint that the recorded history is being corrected). The parent
|
||||
* remounts the dialog via `key` per target, so the prefill is a plain
|
||||
* lazy state init. Client-side guards mirror the server validation
|
||||
* (times present, Von before Bis); the server stays authoritative for
|
||||
* overlap / before-current-plan and its message surfaces through the
|
||||
* error toast.
|
||||
*/
|
||||
function AddEraDialog({
|
||||
function EraDialog({
|
||||
medicationId,
|
||||
target,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
onSaved,
|
||||
}: {
|
||||
medicationId: string;
|
||||
/** Era being edited; null = the add flow. */
|
||||
target: ScheduleRevisionRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [times, setTimes] = useState<string[]>([]);
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [untilDate, setUntilDate] = useState("");
|
||||
const [times, setTimes] = useState<string[]>(() =>
|
||||
target ? eraTimes(target.entries) : [],
|
||||
);
|
||||
const [fromDate, setFromDate] = useState(() =>
|
||||
target ? toLocalDateInput(target.validFrom) : "",
|
||||
);
|
||||
const [untilDate, setUntilDate] = useState(() =>
|
||||
target ? toLocalDateInput(target.validUntil) : "",
|
||||
);
|
||||
const [validationKey, setValidationKey] = useState<
|
||||
"timesMissing" | "rangeMissing" | "rangeOrder" | null
|
||||
>(null);
|
||||
|
||||
function reset() {
|
||||
setTimes([]);
|
||||
setFromDate("");
|
||||
setUntilDate("");
|
||||
setTimes(target ? eraTimes(target.entries) : []);
|
||||
setFromDate(target ? toLocalDateInput(target.validFrom) : "");
|
||||
setUntilDate(target ? toLocalDateInput(target.validUntil) : "");
|
||||
setValidationKey(null);
|
||||
}
|
||||
|
||||
@@ -383,21 +441,34 @@ function AddEraDialog({
|
||||
validUntil: string;
|
||||
timesOfDay: string[];
|
||||
}) =>
|
||||
apiPost<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions`,
|
||||
input,
|
||||
),
|
||||
target
|
||||
? apiPatch<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions/${target.id}`,
|
||||
input,
|
||||
)
|
||||
: apiPost<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success(t("medications.detail.zeitplan.history.createdToast"));
|
||||
toast.success(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updatedToast")
|
||||
: t("medications.detail.zeitplan.history.createdToast"),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationDetail(medicationId),
|
||||
});
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
onCreated();
|
||||
onSaved();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("medications.detail.zeitplan.history.createError"));
|
||||
toast.error(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updateError")
|
||||
: t("medications.detail.zeitplan.history.createError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -437,12 +508,24 @@ function AddEraDialog({
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.zeitplan.history.addEraTitle")}
|
||||
{target
|
||||
? t("medications.detail.zeitplan.history.editEraTitle")
|
||||
: t("medications.detail.zeitplan.history.addEraTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.zeitplan.history.addEraDescription")}
|
||||
{target
|
||||
? t("medications.detail.zeitplan.history.editEraDescription")
|
||||
: t("medications.detail.zeitplan.history.addEraDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{target?.source === "ARCHIVED" && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="zeitplan-history-archived-hint"
|
||||
>
|
||||
{t("medications.detail.zeitplan.history.archivedEditHint")}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,6 +19,8 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppSettingsProvider } from "@/components/app-settings-provider";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
|
||||
// ── Theme Context ────────────────────────────────────
|
||||
|
||||
@@ -109,6 +116,26 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dashboard snapshot preloader ─────────────────────
|
||||
//
|
||||
// v1.16.6 first-load waterfall fix: fire the snapshot fetch the moment
|
||||
// the router commits to "/" instead of waiting for the dashboard page
|
||||
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
|
||||
// The proxy has already enforced auth + onboarding for "/" before any
|
||||
// client code runs here, so the prefetch cannot fire for an
|
||||
// unauthenticated visitor; a racing edge case just yields a swallowed
|
||||
// 401 prefetch and the mounted cell re-resolves normally.
|
||||
function DashboardSnapshotPreloader() {
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
if (pathname === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
}
|
||||
}, [pathname, queryClient]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
export function Providers({
|
||||
@@ -143,6 +170,7 @@ export function Providers({
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<AppSettingsProvider>
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
|
||||
@@ -121,7 +121,9 @@ describe("<UpdateBadge> — a11y contract", () => {
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain(
|
||||
'aria-label="A newer version is available: v1.4.99"',
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
expect(html).toContain("focus-visible:ring-ring");
|
||||
@@ -140,7 +142,9 @@ describe("<UpdateBadge> — a11y contract", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain(
|
||||
'aria-label="A newer version is available: v1.4.99"',
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ describe("resolveInitialTimezone — v1.4.37 silent auto-seed", () => {
|
||||
});
|
||||
|
||||
it("respects any non-Berlin stored value — explicit user choice wins over browser detection", () => {
|
||||
expect(
|
||||
resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin"),
|
||||
).toBe("America/Los_Angeles");
|
||||
expect(resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin")).toBe(
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects a non-Berlin stored value even when the browser reports a different non-Berlin zone", () => {
|
||||
|
||||
@@ -30,9 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: () => (
|
||||
<div data-slot="mock-ack-dialog" />
|
||||
),
|
||||
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
@@ -137,9 +137,7 @@ describe("<AdvancedSection> → Research Mode card", () => {
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain(
|
||||
'data-slot="settings-research-mode-reprompt-cta"',
|
||||
);
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
|
||||
@@ -290,17 +290,19 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// The pin-only ids reuse `measurements.type*` labels that resolve to
|
||||
// distinctive English strings; none should appear as a toggle aria-label.
|
||||
const pinOnlyLabels: Record<(typeof IOS_PIN_ONLY_WIDGET_IDS)[number], string> =
|
||||
{
|
||||
cardioRecovery: "Cardio recovery",
|
||||
sixMinuteWalk: "Six-minute walk distance",
|
||||
stairAscentSpeed: "Stair ascent speed",
|
||||
stairDescentSpeed: "Stair descent speed",
|
||||
breathingDisturbances: "Breathing disturbances",
|
||||
wristTemperature: "Wrist temperature",
|
||||
falls: "Falls",
|
||||
walkingSteadiness: "Walking steadiness",
|
||||
};
|
||||
const pinOnlyLabels: Record<
|
||||
(typeof IOS_PIN_ONLY_WIDGET_IDS)[number],
|
||||
string
|
||||
> = {
|
||||
cardioRecovery: "Cardio recovery",
|
||||
sixMinuteWalk: "Six-minute walk distance",
|
||||
stairAscentSpeed: "Stair ascent speed",
|
||||
stairDescentSpeed: "Stair descent speed",
|
||||
breathingDisturbances: "Breathing disturbances",
|
||||
wristTemperature: "Wrist temperature",
|
||||
falls: "Falls",
|
||||
walkingSteadiness: "Walking steadiness",
|
||||
};
|
||||
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
|
||||
// The widget label drives the switch aria-label; if the row were
|
||||
// rendered the localised label would show up in the markup.
|
||||
|
||||
@@ -27,11 +27,7 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ImportPanel,
|
||||
EXAMPLE_IMPORT,
|
||||
parseImportJson,
|
||||
} from "../import-panel";
|
||||
import { ImportPanel, EXAMPLE_IMPORT, parseImportJson } from "../import-panel";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -61,11 +61,12 @@ beforeEach(() => {
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,7 +113,9 @@ describe("Settings — TimeFormatSelect", () => {
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
|
||||
expect(html).toMatch(
|
||||
/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to AUTO when the user has no stored preference", () => {
|
||||
@@ -122,14 +124,14 @@ describe("Settings — TimeFormatSelect", () => {
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
|
||||
expect(html).toMatch(
|
||||
/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the select when unauthenticated", () => {
|
||||
const html = render(false);
|
||||
const select = html.match(
|
||||
/<select[^>]*settings-time-format-select[^>]*>/,
|
||||
);
|
||||
const select = html.match(/<select[^>]*settings-time-format-select[^>]*>/);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select![0]).toContain("disabled");
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ export function AboutMeSection({
|
||||
<section
|
||||
aria-labelledby="settings-ai-about-me-title"
|
||||
data-testid="settings-about-me-card"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -234,10 +234,7 @@ export function AboutSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-about-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-about-title" className="sr-only">
|
||||
{t("settings.sections.about.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -249,7 +246,7 @@ export function AboutSection() {
|
||||
stacked the license under the version inside its own boxed Badge,
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
|
||||
|
||||
{isLoading || !version ? (
|
||||
@@ -319,7 +316,7 @@ export function AboutSection() {
|
||||
About surface, so the buttons floated alone with no heading
|
||||
telling the user what the section was for. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BookOpen}
|
||||
title={t("settings.about.linksHeading")}
|
||||
@@ -368,7 +365,7 @@ export function AboutSection() {
|
||||
links. Mirrors the stack-on-mobile / right-align-on-desktop
|
||||
contract used by the Account section's "Restart onboarding
|
||||
tour" card so the two surfaces feel consistent. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
|
||||
@@ -414,10 +414,7 @@ export function AccountSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-account-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-account-title" className="sr-only">
|
||||
{t("settings.sections.account.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -429,7 +426,7 @@ export function AccountSection() {
|
||||
{/* Profile photo card */}
|
||||
<AvatarSection />
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={User}
|
||||
title={t("settings.profile")}
|
||||
@@ -648,7 +645,7 @@ export function AccountSection() {
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Passkeys card */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passkeys")}
|
||||
@@ -692,7 +689,7 @@ export function AccountSection() {
|
||||
(the German "Passwort ändern" already pushes it; the tour
|
||||
card's "Restart onboarding tour" actually broke through the
|
||||
right border by ~48 px). */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
@@ -722,7 +719,7 @@ export function AccountSection() {
|
||||
background reopens the spotlight tour immediately. v1.4.19
|
||||
A6 — same stack-on-mobile / right-align-on-desktop contract
|
||||
as the password card so both action surfaces look identical. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
@@ -1190,7 +1187,7 @@ function AvatarSection() {
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -56,10 +57,7 @@ export function AdvancedSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-advanced-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-advanced-title" className="sr-only">
|
||||
{t("settings.sections.advanced.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -151,18 +149,14 @@ function ResearchModeCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-research-mode-card"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.researchMode.sectionTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("settings.researchMode.subtitle")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={BookOpenCheck}
|
||||
title={t("settings.researchMode.sectionTitle")}
|
||||
description={t("settings.researchMode.subtitle")}
|
||||
/>
|
||||
|
||||
{showRePrompt && (
|
||||
<div
|
||||
@@ -270,7 +264,7 @@ function DataResetCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-data-reset-card"
|
||||
>
|
||||
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
|
||||
@@ -418,7 +412,7 @@ function AccountDeleteCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-account-delete-card"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
|
||||
@@ -16,23 +16,31 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
// prop on `useMounted()` so the hydration render matches the
|
||||
// server-rendered "not yet authenticated" state and the cards enable
|
||||
// on the first client re-render.
|
||||
const mounted = useMounted();
|
||||
const authed = mounted && isAuthenticated;
|
||||
// v1.11.2 — the Coach-memory panel rides the same gate as the rest of
|
||||
// the Coach surface: when the user has hidden the Coach there is no
|
||||
// assistant to remember anything, so the memory controls hide too.
|
||||
const coachEnabled = !user?.disableCoach;
|
||||
// (`mounted`-gated like `authed`: SSR renders the panels, so the
|
||||
// hydration render must too, even when the user disabled the Coach.)
|
||||
const coachEnabled = !mounted || !user?.disableCoach;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-ai-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-ai-title" className="sr-only">
|
||||
{t("settings.sections.ai.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -40,27 +48,27 @@ export function AiSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={isAuthenticated} />
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
|
||||
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
|
||||
card so users who want the surface gone never have to scroll
|
||||
through provider configuration before finding the toggle. */}
|
||||
<DisableCoachCard isAuthenticated={isAuthenticated} />
|
||||
<DisableCoachCard isAuthenticated={authed} />
|
||||
|
||||
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
|
||||
moved here from the in-chat sheet; the chat header gear
|
||||
deep-links to this section. Gated on the Coach being
|
||||
enabled. */}
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
|
||||
|
||||
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
|
||||
forget controls. Gated on the Coach being enabled. */}
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
|
||||
{/* v1.15.20 — user-authored "about me" context for the Coach +
|
||||
daily briefing. Not gated on the Coach toggle: the briefing
|
||||
reads it too. */}
|
||||
<AboutMeSection isAuthenticated={isAuthenticated} />
|
||||
<AboutMeSection isAuthenticated={authed} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function AiInsightsCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
{/* The card used to render an icon-only header row (Sparkles +
|
||||
status badges, no title), which left the tile unanchored next
|
||||
to its titled siblings. It now follows the shared
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -74,7 +75,12 @@ export function DisableCoachCard({
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const checked = optimistic ?? user?.disableCoach ?? false;
|
||||
// `mounted`-gated: `user` comes from the auth query, which can
|
||||
// resolve before this boundary hydrates — the Switch state must
|
||||
// match the unchecked SSR HTML during hydration (React #418) and
|
||||
// pick up the wire value on the first client re-render.
|
||||
const mounted = useMounted();
|
||||
const checked = mounted ? (optimistic ?? user?.disableCoach ?? false) : false;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
@@ -122,7 +128,7 @@ export function DisableCoachCard({
|
||||
<section
|
||||
aria-labelledby="settings-ai-disable-coach-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircleOff}
|
||||
|
||||
@@ -42,10 +42,7 @@ export function ApiSection() {
|
||||
return (
|
||||
<section aria-labelledby="settings-section-api-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-api-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-api-title" className="sr-only">
|
||||
{t("settings.sections.api.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -78,7 +75,7 @@ function ApiEndpointsCard() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.apiEndpointsTitle")}
|
||||
@@ -241,7 +238,7 @@ function ApiTokensCard() {
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.apiTokens")}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { Brain, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
@@ -136,70 +137,65 @@ export function CoachMemorySection({
|
||||
<section
|
||||
aria-labelledby="settings-ai-coach-memory-title"
|
||||
data-testid="settings-coach-memory-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<h2
|
||||
id="settings-ai-coach-memory-title"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t("settings.ai.coachMemory.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.description")}
|
||||
</p>
|
||||
</div>
|
||||
{facts.length > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
<SettingsCardHeader
|
||||
icon={Brain}
|
||||
titleId="settings-ai-coach-memory-title"
|
||||
title={t("settings.ai.coachMemory.title")}
|
||||
description={t("settings.ai.coachMemory.description")}
|
||||
status={
|
||||
facts.length > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isError && (
|
||||
<p
|
||||
|
||||
@@ -172,7 +172,7 @@ export function CoachNudgeCard({
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-coach-nudge-title"
|
||||
className="bg-card rounded-lg border p-4 sm:p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-testid="settings-coach-nudge-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -19,17 +19,10 @@
|
||||
* contradict each other.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -41,14 +34,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
type CoachDataCluster,
|
||||
type CoachDefaultWindow,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
@@ -76,15 +66,6 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
|
||||
"anthropometrics",
|
||||
];
|
||||
|
||||
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
|
||||
|
||||
export interface CoachPrefsSectionProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
@@ -122,36 +103,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
|
||||
setDraft((prev) => {
|
||||
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
|
||||
const updated = next
|
||||
? Array.from(new Set([...current, cluster]))
|
||||
: current.filter((c) => c !== cluster);
|
||||
return { ...prev, dataClusters: updated };
|
||||
});
|
||||
}
|
||||
|
||||
const enabledClusters = new Set<CoachDataCluster>(
|
||||
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
|
||||
);
|
||||
|
||||
const isLoading = isAuthenticated && !persisted;
|
||||
|
||||
return (
|
||||
<Card data-slot="coach-prefs-section">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("insights.coach.settingsTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("insights.coach.settingsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="coach-prefs-section"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={SlidersHorizontal}
|
||||
title={t("insights.coach.settingsTitle")}
|
||||
description={t("insights.coach.settingsDescription")}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<CardContent
|
||||
<div
|
||||
data-slot="coach-prefs-skeleton"
|
||||
className="flex flex-col gap-5"
|
||||
className="mt-4 flex flex-col gap-5 pl-7"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
@@ -166,9 +133,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="mt-4 flex flex-col gap-5 pl-7">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -181,7 +148,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
|
||||
<SelectTrigger
|
||||
id="coach-prefs-tone"
|
||||
data-slot="coach-prefs-tone"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -236,84 +206,24 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default analysis window */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-default-window"
|
||||
className="text-xs font-medium"
|
||||
{/* Data clusters + analysis window live on ONE owner: the
|
||||
sources rail inside the chat ("What I draw on"). This card
|
||||
used to render its own copies of both controls; two writable
|
||||
surfaces for the same persisted fields is exactly the
|
||||
redundancy the rail was built to end. A pointer replaces
|
||||
them. */}
|
||||
<p
|
||||
data-slot="coach-prefs-sources-pointer"
|
||||
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-[11px] leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.settingsSourcesPointer")}{" "}
|
||||
<Link
|
||||
href="/insights/coach"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.coach.settingsDefaultWindowLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsDefaultWindowHint")}
|
||||
</p>
|
||||
<Select
|
||||
value={draft.defaultWindow}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
defaultWindow: value as CoachDefaultWindow,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-default-window"
|
||||
data-slot="coach-prefs-default-window"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_WINDOW_OPTIONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`insights.coach.window.${w}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Clustered, opt-in data sources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsClustersLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsClustersHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-cluster-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CLUSTER_OPTIONS.map((cluster) => {
|
||||
const id = `coach-prefs-cluster-${cluster}`;
|
||||
const checked = enabledClusters.has(cluster);
|
||||
return (
|
||||
<li
|
||||
key={cluster}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(`insights.coach.cluster.${cluster}.label`)}
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[11px] leading-snug">
|
||||
{t(`insights.coach.cluster.${cluster}.hint`)}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-cluster-${cluster}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleCluster(cluster, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{t("insights.coach.settingsSourcesPointerLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Exclude metrics — checked = excluded. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -386,9 +296,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
)}
|
||||
<CardFooter className="justify-end">
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -404,7 +314,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function CycleTrackingCard({
|
||||
const enabled = data?.cycleTrackingEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-4 sm:p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.cycleTracking.title")}
|
||||
|
||||
@@ -294,7 +294,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
|
||||
right-align-on-desktop contract as Account → Password +
|
||||
|
||||
@@ -12,10 +12,7 @@ export function DashboardSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-dashboard-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-dashboard-title" className="sr-only">
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -79,10 +79,7 @@ export function ExportSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-export-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-export-title" className="sr-only">
|
||||
{t("settings.sections.export.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -158,7 +155,7 @@ function ExportCardShell({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-6${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -505,7 +502,10 @@ function CycleExportCard() {
|
||||
const prefsQuery = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>("/api/auth/me/cycle-prefs", { credentials: "include" });
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>(
|
||||
"/api/auth/me/cycle-prefs",
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -93,10 +93,13 @@ function ImportCardShell({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
|
||||
className="bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-muted-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
@@ -144,7 +147,10 @@ function AppleHealthImportCard() {
|
||||
return 2000;
|
||||
},
|
||||
queryFn: async (): Promise<JobStatus> => {
|
||||
return apiGet<JobStatus>(`/api/import/apple-health-export/${jobId}/status`, { credentials: "include" });
|
||||
return apiGet<JobStatus>(
|
||||
`/api/import/apple-health-export/${jobId}/status`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ export function InjectionSitesCard({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: InjectionSiteKey[]) => {
|
||||
await apiPatch("/api/auth/me/injection-site-prefs", { globalExcludedInjectionSites: next });
|
||||
await apiPatch("/api/auth/me/injection-site-prefs", {
|
||||
globalExcludedInjectionSites: next,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
|
||||
@@ -125,12 +125,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (tiles: InsightsTileConfig[]) => {
|
||||
return apiPut<InsightsLayout>("/api/insights/layout", {
|
||||
version: 2,
|
||||
// Preserve the overview section order/visibility verbatim — this
|
||||
// surface owns the PILL order only.
|
||||
sections: layout.sections,
|
||||
tiles,
|
||||
});
|
||||
version: 2,
|
||||
// Preserve the overview section order/visibility verbatim — this
|
||||
// surface owns the PILL order only.
|
||||
sections: layout.sections,
|
||||
tiles,
|
||||
});
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
|
||||
@@ -24,10 +24,7 @@ export function InsightsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-insights-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-insights-title" className="sr-only">
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -106,12 +106,9 @@ export function IntegrationStatusPill({
|
||||
// a reauth won't fix" vs "tried to keep going for 24h but the mismatch
|
||||
// persists, manual intervention required" — distinct from the red
|
||||
// reconnect pill (`error`).
|
||||
const connectedChipClass =
|
||||
"border-success/30 bg-success/15 text-success";
|
||||
const warningChipClass =
|
||||
"border-warning/30 bg-warning/15 text-warning";
|
||||
const parkedChipClass =
|
||||
"border-warning/30 bg-warning/15 text-warning";
|
||||
const connectedChipClass = "border-success/30 bg-success/15 text-success";
|
||||
const warningChipClass = "border-warning/30 bg-warning/15 text-warning";
|
||||
const parkedChipClass = "border-warning/30 bg-warning/15 text-warning";
|
||||
|
||||
let label: string;
|
||||
let icon: React.ReactNode;
|
||||
|
||||
@@ -99,10 +99,7 @@ export function IntegrationsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-integrations-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-integrations-title" className="sr-only">
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { HeartPulse, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
|
||||
import {
|
||||
HeartPulse,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -182,7 +189,7 @@ export function FitbitCard({
|
||||
return (
|
||||
<div
|
||||
data-testid="fitbit-card"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -174,7 +181,7 @@ export function WhoopCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("settings.whoop")}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function WithingsCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.withings")}
|
||||
|
||||
@@ -107,7 +107,9 @@ export function MoodReminderCard({
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
} else {
|
||||
@@ -155,7 +157,7 @@ export function MoodReminderCard({
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-mood-reminder-title"
|
||||
className="bg-card rounded-lg border p-4 sm:p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={SmilePlus}
|
||||
@@ -180,10 +182,7 @@ export function MoodReminderCard({
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
|
||||
<label
|
||||
htmlFor="mood-reminder-hour"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
|
||||
{t("notifications.moodReminder.hourLabel")}
|
||||
</label>
|
||||
<NativeSelect
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -113,7 +114,7 @@ export function NotificationStatusCard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
@@ -128,37 +129,30 @@ export function NotificationStatusCard() {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-testid="notification-status-empty"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.notificationStatus.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-sm">
|
||||
{t("settings.notificationStatus.emptyDescription")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
description={t("settings.notificationStatus.emptyDescription")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<header className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.notificationStatus.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.notificationStatus.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
description={t("settings.notificationStatus.description")}
|
||||
/>
|
||||
|
||||
<ul className="space-y-3" data-testid="notification-status-list">
|
||||
<ul
|
||||
className="mt-4 space-y-3 pl-7"
|
||||
data-testid="notification-status-list"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<ChannelRow
|
||||
key={ch.id}
|
||||
|
||||
@@ -74,10 +74,7 @@ export function NotificationsSection() {
|
||||
</ol>
|
||||
</nav>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-notifications-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-notifications-title" className="sr-only">
|
||||
{t("settings.sections.notifications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -82,7 +82,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.ntfy")}
|
||||
|
||||
@@ -70,10 +70,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id={`settings-section-${slug}-title`}
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id={`settings-section-${slug}-title`} className="sr-only">
|
||||
{sectionTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -285,10 +286,7 @@ export function SourcesSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sources-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-sources-title" className="sr-only">
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -308,29 +306,23 @@ export function SourcesSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.sections.sources.cardTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || saveMutation.isPending}
|
||||
className="self-end sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.sources.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.sections.sources.help")}
|
||||
</p>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Layers}
|
||||
title={t("settings.sections.sources.cardTitle")}
|
||||
description={t("settings.sections.sources.help")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || saveMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.sources.resetDefaults")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !priority ? (
|
||||
<SourcesSkeletonList />
|
||||
|
||||
@@ -87,7 +87,7 @@ export function TelegramCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircle}
|
||||
title={t("settings.telegram")}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
|
||||
already provides the title + description for this route, so the
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user