mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #282 from MBombeck/release/v1.15.6
release/v1.15.6 — settings, insights, and medication-card polish
This commit is contained in:
45 files changed
+809
-2323
No files matched your search
@@ -2,6 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.6] — 2026-06-06 — settings, insights, and medication-card polish
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The notifications settings panel no longer crashes.** Opening Settings → Notifications could throw a client-side error on some accounts; the panel now renders safely whatever shape the data arrives in.
|
||||
- **Even chevron in dropdowns.** The arrow on the right of selects (timezone, language, units, and everywhere else) now sits with the same inset from the right edge as the label has from the left, instead of pushing against the edge.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clearer Insights overview.** The period-in-review card now carries a proper heading, and the "signals of the day" section only appears when there's an actual anomaly to flag — when every vital is within your personal range it stays hidden rather than showing an empty header.
|
||||
- **Tidier medication cards.** The next-dose line no longer appears twice on a card, and the injection-site suggestion text has been dropped from the card surface (site tracking and the post-dose picker are unchanged).
|
||||
- **The avatar menu lands in Account.** Clicking your avatar now opens the Account settings directly.
|
||||
- **Shorter integration save button.** The integrations credential form's button now simply reads "Save".
|
||||
- **Tidier API-tokens panel.** The token endpoint URL is shown inline with a copy button, and the "generate another token" button matches the rest of the page rather than standing out in a different colour.
|
||||
- **The export panel's create button is right-aligned**, consistent with other primary actions.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The moodLog integration** has been removed from the Integrations page; it is no longer used.
|
||||
- **The standalone sharing (create-link) feature** has been removed from the Export area.
|
||||
- **The doctor report is no longer offered twice** — it has been removed from the main Export page, where it duplicated the health-record export's own doctor report.
|
||||
|
||||
## [1.15.5] — 2026-06-06 — insights layout, navigation, password-reset CLI
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.15.5
|
||||
version: 1.15.6
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Doctor-report PDF flow. The real flow:
|
||||
* 1. User clicks "Configure & generate" on `/settings/export`
|
||||
* (relocated from `/settings/advanced` in v1.4.16 phase B7)
|
||||
* 2. The dialog opens; user picks dates and submits.
|
||||
* 3. Frontend POSTs `/api/doctor-report` to fetch aggregated data
|
||||
* 4. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
|
||||
* generates the PDF client-side via jsPDF; calls `doc.save(...)`
|
||||
* which triggers a download.
|
||||
*
|
||||
* This spec mocks step 3 (so we don't need 90 days of seeded data) and
|
||||
* captures step 4's download via Playwright's download API. We then
|
||||
* confirm the file is non-empty and starts with `%PDF` magic bytes.
|
||||
*/
|
||||
test.describe("doctor report PDF generation", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("button click triggers a non-empty PDF download", async ({ page }) => {
|
||||
// Mock the data endpoint with a minimal-but-shape-correct payload.
|
||||
// The shape below mirrors the `ReportData` interface in
|
||||
// `src/lib/doctor-report-pdf.ts` — empty stats / medications / mood
|
||||
// is enough to exercise the table-rendering path without 90 days
|
||||
// of seeded readings.
|
||||
await page.route("**/api/doctor-report", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
period: {
|
||||
days: 90,
|
||||
since: new Date(Date.now() - 90 * 86_400_000).toISOString(),
|
||||
},
|
||||
patient: {
|
||||
username: "e2e-tester",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
},
|
||||
stats: {},
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Capture page errors so a failed dynamic-import surfaces in the
|
||||
// test output instead of timing out silently.
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(`pageerror: ${err.message}`));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error")
|
||||
pageErrors.push(`console.error: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// v1.4.37 renamed the trigger testid from `export-action-doctor-report`
|
||||
// to `export-hero-doctor-report-action` when the card was lifted to a
|
||||
// hero block on the export page.
|
||||
const reportBtn = page.getByTestId("export-hero-doctor-report-action");
|
||||
await expect(reportBtn).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click opens the dialog; the dialog's submit button triggers the PDF
|
||||
// generation. Trigger the download and capture the resulting Download
|
||||
// object once the user confirms.
|
||||
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
|
||||
await reportBtn.click();
|
||||
// Submit the dialog with the default dates (last 90 days).
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /generate pdf|pdf generieren/i,
|
||||
});
|
||||
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
||||
await submitBtn.click();
|
||||
const download = await downloadPromise.catch((err) => {
|
||||
throw new Error(
|
||||
`Download did not fire. Page errors:\n${pageErrors.join("\n")}\n\nOriginal: ${err}`,
|
||||
);
|
||||
});
|
||||
|
||||
const path = await download.path();
|
||||
expect(path).toBeTruthy();
|
||||
|
||||
// Read first 4 bytes — a valid PDF starts with `%PDF`.
|
||||
const fs = await import("node:fs/promises");
|
||||
const buf = await fs.readFile(path!);
|
||||
expect(buf.byteLength).toBeGreaterThan(1024);
|
||||
expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF");
|
||||
});
|
||||
});
|
||||
@@ -24,16 +24,16 @@ test.describe("Settings → Export", () => {
|
||||
|
||||
test("renders all export surfaces with stable testids", async ({ page }) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
// v1.12 — the health-record export is the page hero; the
|
||||
// doctor-report card (`export-hero-*` prefix) is demoted to the
|
||||
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
|
||||
// The health-record export is the page hero; the four CSV/backup
|
||||
// tiles keep the `export-card-*` shape. The doctor report lives
|
||||
// under the health-record export, so it is no longer a separate
|
||||
// surface here.
|
||||
for (const id of [
|
||||
"health-record-export-panel",
|
||||
"export-card-measurements-csv",
|
||||
"export-card-medications-csv",
|
||||
"export-card-mood-csv",
|
||||
"export-card-full-backup",
|
||||
"export-hero-doctor-report",
|
||||
]) {
|
||||
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
@@ -15,11 +15,10 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* verifies neither path leaks into a horizontal scrollbar on the
|
||||
* <html> element.
|
||||
*
|
||||
* 2. Render exactly one pill per integration card (Withings + Mood
|
||||
* Log when both are configured). Belt-and-braces guard for the
|
||||
* consolidation Marc requested — the Vitest spec covers this on
|
||||
* the SSR markup level, but the Pixel-5 layout is what matters
|
||||
* to him in practice.
|
||||
* 2. Render exactly one pill per integration card. Belt-and-braces
|
||||
* guard for the card consolidation — the Vitest spec covers this
|
||||
* on the SSR markup level, but the Pixel-5 layout is what matters
|
||||
* in practice.
|
||||
*
|
||||
* Mobile-only spec — desktop project skips it.
|
||||
*/
|
||||
@@ -144,18 +143,18 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
// Four integration cards now (Withings, moodLog, WHOOP, Google Health) →
|
||||
// four pills.
|
||||
await expect(pills).toHaveCount(4, { timeout: 10_000 });
|
||||
// Three integration cards now (Withings, WHOOP, Google Health) → three
|
||||
// pills.
|
||||
await expect(pills).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Withings + moodLog are connected in this fixture; WHOOP is a BYO-keys
|
||||
// card that stays dormant until an operator adds credentials, so assert
|
||||
// the two connected pills order-independently.
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
// connected pill.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="integration-status-pill"][data-state="connected"]',
|
||||
),
|
||||
).toHaveCount(2);
|
||||
).toHaveCount(1);
|
||||
|
||||
// The redundant v1.4.18 banner must be gone.
|
||||
await expect(
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Hell",
|
||||
"collapseSidebar": "Einklappen",
|
||||
"userMenu": "Benutzermenü",
|
||||
"accountSettings": "Kontoeinstellungen",
|
||||
"mood": "Stimmung",
|
||||
"workouts": "Workouts",
|
||||
"skipToContent": "Zum Inhalt springen",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Einschätzung neu erstellen",
|
||||
"regenerateSuccess": "Einschätzung wurde neu erstellt",
|
||||
"warmStarted": "Einschätzungen werden im Hintergrund erstellt",
|
||||
"narrativeTitle": "Dein Zeitraum im Rückblick",
|
||||
"narrativeTitle": "Zeitraum im Rückblick",
|
||||
"narrativeWeek": "Diese Woche",
|
||||
"narrativeMonth": "Dieser Monat",
|
||||
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"withingsCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
|
||||
"withingsSaveCredentials": "Zugangsdaten speichern",
|
||||
"withingsSaveCredentials": "Speichern",
|
||||
"configured": "Konfiguriert",
|
||||
"withingsSync": "Jetzt synchronisieren",
|
||||
"withingsFullSync": "Alle Daten synchronisieren",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"whoopSaveCredentials": "Zugangsdaten speichern",
|
||||
"whoopSaveCredentials": "Speichern",
|
||||
"whoopSync": "Jetzt synchronisieren",
|
||||
"whoopFullSync": "Alle Daten synchronisieren",
|
||||
"whoopFullSyncTitle": "Vollständige Synchronisierung?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"fitbitSaveCredentials": "Zugangsdaten speichern",
|
||||
"fitbitSaveCredentials": "Speichern",
|
||||
"fitbitSync": "Jetzt synchronisieren",
|
||||
"fitbitFullSync": "Alle Daten synchronisieren",
|
||||
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Light",
|
||||
"collapseSidebar": "Collapse",
|
||||
"userMenu": "User menu",
|
||||
"accountSettings": "Account settings",
|
||||
"mood": "Mood",
|
||||
"workouts": "Workouts",
|
||||
"skipToContent": "Skip to content",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Re-run assessment",
|
||||
"regenerateSuccess": "Assessment refreshed",
|
||||
"warmStarted": "Assessments are being prepared in the background",
|
||||
"narrativeTitle": "Your period in review",
|
||||
"narrativeTitle": "Period in review",
|
||||
"narrativeWeek": "This week",
|
||||
"narrativeMonth": "This month",
|
||||
"narrativePreparing": "Preparing your summary…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Credentials saved",
|
||||
"withingsCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"withingsSaveCredentials": "Save credentials",
|
||||
"withingsSaveCredentials": "Save",
|
||||
"configured": "Configured",
|
||||
"withingsSync": "Sync now",
|
||||
"withingsFullSync": "Sync all data",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Credentials saved",
|
||||
"whoopCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"whoopSaveCredentials": "Save credentials",
|
||||
"whoopSaveCredentials": "Save",
|
||||
"whoopSync": "Sync now",
|
||||
"whoopFullSync": "Sync all data",
|
||||
"whoopFullSyncTitle": "Full synchronization?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Credentials saved",
|
||||
"fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"fitbitSaveCredentials": "Save credentials",
|
||||
"fitbitSaveCredentials": "Save",
|
||||
"fitbitSync": "Sync now",
|
||||
"fitbitFullSync": "Sync all data",
|
||||
"fitbitFullSyncTitle": "Full synchronization?",
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Claro",
|
||||
"collapseSidebar": "Contraer",
|
||||
"userMenu": "Menú de usuario",
|
||||
"accountSettings": "Ajustes de cuenta",
|
||||
"mood": "Estado de ánimo",
|
||||
"workouts": "Entrenamientos",
|
||||
"skipToContent": "Saltar al contenido",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Volver a generar la valoración",
|
||||
"regenerateSuccess": "Valoración regenerada",
|
||||
"warmStarted": "Las valoraciones se están preparando en segundo plano",
|
||||
"narrativeTitle": "Tu período en resumen",
|
||||
"narrativeTitle": "Período en resumen",
|
||||
"narrativeWeek": "Esta semana",
|
||||
"narrativeMonth": "Este mes",
|
||||
"narrativePreparing": "Preparando tu resumen…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Credenciales guardadas",
|
||||
"withingsCredentialsSavedPlaceholder": "Guardado — introduce uno nuevo para reemplazarlo",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Guardado — introduce uno nuevo para reemplazarlo",
|
||||
"withingsSaveCredentials": "Guardar credenciales",
|
||||
"withingsSaveCredentials": "Guardar",
|
||||
"configured": "Configurado",
|
||||
"withingsSync": "Sincronizar ahora",
|
||||
"withingsFullSync": "Sincronizar todos los datos",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Credenciales guardadas",
|
||||
"whoopCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar",
|
||||
"whoopSaveCredentials": "Guardar credenciales",
|
||||
"whoopSaveCredentials": "Guardar",
|
||||
"whoopSync": "Sincronizar ahora",
|
||||
"whoopFullSync": "Sincronizar todos los datos",
|
||||
"whoopFullSyncTitle": "¿Sincronización completa?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Credenciales guardadas",
|
||||
"fitbitCredentialsSavedPlaceholder": "Guardado: introduce nuevas para reemplazar",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Guardado: introduce nuevas para reemplazar",
|
||||
"fitbitSaveCredentials": "Guardar credenciales",
|
||||
"fitbitSaveCredentials": "Guardar",
|
||||
"fitbitSync": "Sincronizar ahora",
|
||||
"fitbitFullSync": "Sincronizar todos los datos",
|
||||
"fitbitFullSyncTitle": "¿Sincronización completa?",
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Clair",
|
||||
"collapseSidebar": "Réduire",
|
||||
"userMenu": "Menu utilisateur",
|
||||
"accountSettings": "Paramètres du compte",
|
||||
"mood": "Humeur",
|
||||
"workouts": "Entraînements",
|
||||
"skipToContent": "Aller au contenu",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Relancer l’évaluation",
|
||||
"regenerateSuccess": "Évaluation regénérée",
|
||||
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
|
||||
"narrativeTitle": "Votre période en revue",
|
||||
"narrativeTitle": "Période en revue",
|
||||
"narrativeWeek": "Cette semaine",
|
||||
"narrativeMonth": "Ce mois-ci",
|
||||
"narrativePreparing": "Préparation de votre résumé…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Identifiants enregistrés",
|
||||
"withingsCredentialsSavedPlaceholder": "Enregistré — saisissez-en un nouveau pour remplacer",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en un nouveau pour remplacer",
|
||||
"withingsSaveCredentials": "Enregistrer les identifiants",
|
||||
"withingsSaveCredentials": "Enregistrer",
|
||||
"configured": "Configuré",
|
||||
"withingsSync": "Synchroniser maintenant",
|
||||
"withingsFullSync": "Synchroniser toutes les données",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Identifiants enregistrés",
|
||||
"whoopCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"whoopSaveCredentials": "Enregistrer les identifiants",
|
||||
"whoopSaveCredentials": "Enregistrer",
|
||||
"whoopSync": "Synchroniser",
|
||||
"whoopFullSync": "Synchroniser toutes les données",
|
||||
"whoopFullSyncTitle": "Synchronisation complète ?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Identifiants enregistrés",
|
||||
"fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer",
|
||||
"fitbitSaveCredentials": "Enregistrer les identifiants",
|
||||
"fitbitSaveCredentials": "Enregistrer",
|
||||
"fitbitSync": "Synchroniser maintenant",
|
||||
"fitbitFullSync": "Tout synchroniser",
|
||||
"fitbitFullSyncTitle": "Synchronisation complète ?",
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Chiaro",
|
||||
"collapseSidebar": "Riduci",
|
||||
"userMenu": "Menu utente",
|
||||
"accountSettings": "Impostazioni account",
|
||||
"mood": "Umore",
|
||||
"workouts": "Allenamenti",
|
||||
"skipToContent": "Vai al contenuto",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Rigenera la valutazione",
|
||||
"regenerateSuccess": "Valutazione rigenerata",
|
||||
"warmStarted": "Le valutazioni vengono preparate in background",
|
||||
"narrativeTitle": "Il tuo periodo in sintesi",
|
||||
"narrativeTitle": "Periodo in sintesi",
|
||||
"narrativeWeek": "Questa settimana",
|
||||
"narrativeMonth": "Questo mese",
|
||||
"narrativePreparing": "Preparazione del riepilogo…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Credenziali salvate",
|
||||
"withingsCredentialsSavedPlaceholder": "Salvato — inseriscine uno nuovo per sostituirlo",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Salvato — inseriscine uno nuovo per sostituirlo",
|
||||
"withingsSaveCredentials": "Salva credenziali",
|
||||
"withingsSaveCredentials": "Salva",
|
||||
"configured": "Configurato",
|
||||
"withingsSync": "Sincronizza ora",
|
||||
"withingsFullSync": "Sincronizza tutti i dati",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Credenziali salvate",
|
||||
"whoopCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
|
||||
"whoopSaveCredentials": "Salva credenziali",
|
||||
"whoopSaveCredentials": "Salva",
|
||||
"whoopSync": "Sincronizza ora",
|
||||
"whoopFullSync": "Sincronizza tutti i dati",
|
||||
"whoopFullSyncTitle": "Sincronizzazione completa?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Credenziali salvate",
|
||||
"fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
|
||||
"fitbitSaveCredentials": "Salva credenziali",
|
||||
"fitbitSaveCredentials": "Salva",
|
||||
"fitbitSync": "Sincronizza ora",
|
||||
"fitbitFullSync": "Sincronizza tutti i dati",
|
||||
"fitbitFullSyncTitle": "Sincronizzazione completa?",
|
||||
|
||||
+5
-4
@@ -212,6 +212,7 @@
|
||||
"themeLight": "Jasny",
|
||||
"collapseSidebar": "Zwiń",
|
||||
"userMenu": "Menu użytkownika",
|
||||
"accountSettings": "Ustawienia konta",
|
||||
"mood": "Nastrój",
|
||||
"workouts": "Treningi",
|
||||
"skipToContent": "Przejdź do treści",
|
||||
@@ -1641,7 +1642,7 @@
|
||||
"regenerateAnalysis": "Uruchom ocenę ponownie",
|
||||
"regenerateSuccess": "Ocena wygenerowana ponownie",
|
||||
"warmStarted": "Oceny są przygotowywane w tle",
|
||||
"narrativeTitle": "Twój okres w skrócie",
|
||||
"narrativeTitle": "Okres w skrócie",
|
||||
"narrativeWeek": "Ten tydzień",
|
||||
"narrativeMonth": "Ten miesiąc",
|
||||
"narrativePreparing": "Przygotowywanie podsumowania…",
|
||||
@@ -3583,7 +3584,7 @@
|
||||
"withingsCredentialsSaved": "Dane logowania zapisane",
|
||||
"withingsCredentialsSavedPlaceholder": "Zapisano — wprowadź nowy, aby zastąpić",
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Zapisano — wprowadź nowy, aby zastąpić",
|
||||
"withingsSaveCredentials": "Zapisz dane logowania",
|
||||
"withingsSaveCredentials": "Zapisz",
|
||||
"configured": "Skonfigurowano",
|
||||
"withingsSync": "Synchronizuj teraz",
|
||||
"withingsFullSync": "Synchronizuj wszystkie dane",
|
||||
@@ -3608,7 +3609,7 @@
|
||||
"whoopCredentialsSaved": "Dane zapisane",
|
||||
"whoopCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"whoopCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"whoopSaveCredentials": "Zapisz dane",
|
||||
"whoopSaveCredentials": "Zapisz",
|
||||
"whoopSync": "Synchronizuj teraz",
|
||||
"whoopFullSync": "Synchronizuj wszystkie dane",
|
||||
"whoopFullSyncTitle": "Pełna synchronizacja?",
|
||||
@@ -3635,7 +3636,7 @@
|
||||
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
|
||||
"fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"fitbitSaveCredentials": "Zapisz dane uwierzytelniające",
|
||||
"fitbitSaveCredentials": "Zapisz",
|
||||
"fitbitSync": "Synchronizuj teraz",
|
||||
"fitbitFullSync": "Synchronizuj wszystkie dane",
|
||||
"fitbitFullSyncTitle": "Pełna synchronizacja?",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.5",
|
||||
"version": "1.15.6",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
|
||||
--
|
||||
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
|
||||
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
|
||||
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
|
||||
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
|
||||
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
|
||||
-- board surfaces real findings. role=USER (login is open on prod; an
|
||||
-- admin demo would be a risk). AI insights are generated separately afterwards.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── User (FEMALE, USER role) ──────────────────────────────────────
|
||||
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
SELECT 'usr_demo_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'demo', 'demo@healthlog.app',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
|
||||
|
||||
UPDATE users SET
|
||||
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
|
||||
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
|
||||
WHERE username = 'demo';
|
||||
|
||||
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
|
||||
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
|
||||
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
|
||||
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Cycle profile (tracking ON) ───────────────────────────────────
|
||||
INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, prediction_enabled, raw_chart_mode, discreet_notifications, sensitive_category_encryption, created_at, updated_at)
|
||||
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
|
||||
FROM _demo
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
|
||||
|
||||
UPDATE cycle_profiles SET
|
||||
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
|
||||
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
|
||||
WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
|
||||
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (
|
||||
SELECT ((128 - d) % 28) + 1 AS cd
|
||||
) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc);
|
||||
|
||||
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s;
|
||||
|
||||
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
|
||||
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE 28 END,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs;
|
||||
|
||||
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
|
||||
WITH demo AS (SELECT user_id FROM _demo),
|
||||
gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(0, 127) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Summary ───────────────────────────────────────────────────────
|
||||
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
|
||||
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
@@ -0,0 +1,216 @@
|
||||
-- HealthLog demo 1-year history extender for apps01.
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
|
||||
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
|
||||
-- of realistic medication-intake history for the two pre-existing demo meds.
|
||||
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
|
||||
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
|
||||
-- same phase-aware vitals / mood / cycle channels.
|
||||
--
|
||||
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
|
||||
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
|
||||
-- tail so nothing is duplicated:
|
||||
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
|
||||
-- - cycle day-logs : offsets 128..364
|
||||
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
|
||||
-- - medication intakes : ~365d of due slots for both demo meds
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
|
||||
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
|
||||
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
|
||||
-- so re-running adds nothing new.
|
||||
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
|
||||
-- no table-wide DELETE/UPDATE.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
|
||||
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
|
||||
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
|
||||
-- keep row volume sane while still covering the full year. Unique index
|
||||
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
|
||||
-- measured_at carries a random sub-day jitter, so collisions are effectively
|
||||
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
|
||||
WITH days AS (
|
||||
SELECT d FROM generate_series(90, 364) AS d
|
||||
WHERE d < 180 OR (d % 2) = 0
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
|
||||
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
|
||||
WITH days AS (SELECT generate_series(90, 364) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
|
||||
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
|
||||
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
|
||||
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
28,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs
|
||||
ON CONFLICT (user_id, start_date) DO NOTHING;
|
||||
|
||||
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
|
||||
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
|
||||
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
|
||||
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
|
||||
WITH gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(128, 364) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
ON CONFLICT (user_id, date) DO NOTHING
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
|
||||
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
|
||||
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
|
||||
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
|
||||
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
|
||||
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
|
||||
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
|
||||
-- Never future-dated: WHERE the slot timestamp <= NOW().
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
-- deterministic skip decision per week offset (no random(): re-run stable)
|
||||
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2lzjqh005x01nrys2crzg9',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
|
||||
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
|
||||
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
|
||||
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 364) AS dd
|
||||
CROSS JOIN (VALUES (8), (18)) AS h(hh)
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2m0mzk006101nrdpijbij0',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
|
||||
AND s.sched_utc < date_trunc('day', NOW())
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
|
||||
\echo === post-apply totals (demo user) ===
|
||||
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
|
||||
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
|
||||
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
|
||||
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
|
||||
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
|
||||
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
|
||||
@@ -0,0 +1,153 @@
|
||||
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
|
||||
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
|
||||
-- year of history. These three are normally written by the nightly score
|
||||
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
|
||||
-- Measurement rows; the demo never had them computed, so only the two
|
||||
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
|
||||
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
|
||||
--
|
||||
-- channel type external_id unit value
|
||||
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
|
||||
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
|
||||
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
|
||||
--
|
||||
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
|
||||
-- measurement_type enum (verified live) — both are recomputed on read from
|
||||
-- the underlying signals the demo already carries, so they already render and
|
||||
-- a stored row would be ignored. Seeding them would be dead data.
|
||||
--
|
||||
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
|
||||
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
|
||||
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
|
||||
-- external_source_version / deleted_at all NULL.
|
||||
--
|
||||
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
|
||||
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
|
||||
-- day, so the freshest persisted day is yesterday; this matches the job and
|
||||
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
|
||||
-- 1,095 rows on a clean apply.
|
||||
--
|
||||
-- DETERMINISM
|
||||
-- Values + ids are hash-derived from the day stamp (hashtextextended /
|
||||
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
|
||||
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
|
||||
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
|
||||
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
|
||||
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
|
||||
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── The three persisted score channels, one row per (channel, day) ─────────────
|
||||
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
|
||||
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
|
||||
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
|
||||
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
|
||||
-- rhythm — lighter mid-week, heavier toward the weekend).
|
||||
-- - noise : per-(channel,day) hash in [0,1), no random().
|
||||
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
|
||||
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
|
||||
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
|
||||
-- down) so the two rings tell a coherent story.
|
||||
WITH days AS (
|
||||
SELECT
|
||||
d,
|
||||
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
|
||||
FROM generate_series(1, 365) AS d
|
||||
),
|
||||
calc AS (
|
||||
SELECT
|
||||
d,
|
||||
day_key,
|
||||
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
|
||||
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
|
||||
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
|
||||
-- deterministic per-day "is this a training day" flag (~25%)
|
||||
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
|
||||
-- independent deterministic noise streams per channel, all in [0,1)
|
||||
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
|
||||
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
|
||||
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
|
||||
FROM days
|
||||
),
|
||||
vals AS (
|
||||
SELECT
|
||||
d, day_key, measured_at, train_r,
|
||||
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
|
||||
LEAST(75, GREATEST(20, round(
|
||||
40 + week_phase * 8
|
||||
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
|
||||
+ (str_r - 0.5) * 14
|
||||
)))::int AS strain,
|
||||
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
|
||||
LEAST(90, GREATEST(55, round(
|
||||
74 + week_phase * 6
|
||||
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
|
||||
+ (rec_r - 0.5) * 16
|
||||
)))::int AS recovery
|
||||
FROM calc
|
||||
),
|
||||
final AS (
|
||||
SELECT
|
||||
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
|
||||
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
|
||||
LEAST(65, GREATEST(25, round(
|
||||
90 - v.recovery * 0.5
|
||||
+ (c.stress_r - 0.5) * 14
|
||||
)))::int AS stress
|
||||
FROM vals v JOIN calc c USING (d)
|
||||
),
|
||||
rows AS (
|
||||
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
|
||||
LATERAL (VALUES
|
||||
('RECOVERY_SCORE', 'recovery:', recovery),
|
||||
('STRAIN_SCORE', 'strain:', strain),
|
||||
('STRESS_SCORE', 'stress:', stress)
|
||||
) AS ch(type, ext_prefix, value)
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
-- deterministic id from the per-row external_id → byte-stable re-runs.
|
||||
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
type::measurement_type,
|
||||
value::float8,
|
||||
'score',
|
||||
'COMPUTED'::measurement_source,
|
||||
measured_at,
|
||||
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
|
||||
measured_at,
|
||||
measured_at
|
||||
FROM rows
|
||||
WHERE measured_at <= NOW()
|
||||
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
|
||||
\echo === post-apply score coverage (demo user) ===
|
||||
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
|
||||
FROM measurements
|
||||
WHERE user_id = :'uid'
|
||||
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
|
||||
GROUP BY type
|
||||
ORDER BY type;
|
||||
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
|
||||
@@ -11,7 +11,6 @@ import { ExportSection } from "@/components/settings/export-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
|
||||
// separate sections again. `ThresholdsSection` renders the target-range
|
||||
// editor; `SourcesSection` (standalone mode) renders the source-priority
|
||||
@@ -53,7 +52,6 @@ const SECTION_COMPONENTS: Record<
|
||||
thresholds: ThresholdsSection,
|
||||
sources: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
@@ -115,18 +115,14 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
expect(fired).toContain("min-h-48");
|
||||
});
|
||||
|
||||
it("renders the insufficient (building baselines) state, never an alarm", () => {
|
||||
it("renders nothing in the insufficient (building baselines) state", () => {
|
||||
mock(insufficient());
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="insufficient"');
|
||||
expect(html).toContain('data-slot="coincident-building"');
|
||||
expect(html).toContain('data-slot="coverage-meter"');
|
||||
// Calm, not amber/red.
|
||||
expect(html).not.toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
// No signal yet → the section stays absent rather than an empty husk.
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the all-clear state with the count checked", () => {
|
||||
it("renders nothing in the all-clear state (no signal to show)", () => {
|
||||
mock(
|
||||
ok({
|
||||
fired: false,
|
||||
@@ -136,12 +132,8 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
}),
|
||||
);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="all-clear"');
|
||||
expect(html).toContain("All your vitals are within their personal range");
|
||||
expect(html).toContain("text-success");
|
||||
// No alarm tone.
|
||||
expect(html).not.toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
// Every vital inside its personal range → hide the whole card.
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the watch state for a single out-of-band vital, not the fired tone", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, AlertTriangle, Activity } from "lucide-react";
|
||||
import { AlertTriangle, Activity } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -17,22 +17,20 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
/**
|
||||
* v1.10.3 — "Today's signal" headline card.
|
||||
*
|
||||
* Promotes the COINCIDENT_DEVIATION flag from one buried below-the-fold vitals
|
||||
* tile (painted only when it fired) to a dedicated, always-mounted card at the
|
||||
* top of the Insights overview, matching the always-present pattern Apple
|
||||
* Vitals / WHOOP Health Monitor / Oura Symptom Radar use. The signal is the
|
||||
* daily headline read, not an alarm: an "all clear" day is itself the
|
||||
* reassuring product.
|
||||
* Surfaces the COINCIDENT_DEVIATION flag at the top of the Insights overview as
|
||||
* a calm awareness read drawn off the SINGLE existing `Derived` payload — no new
|
||||
* engine math, no new route, no schema change.
|
||||
*
|
||||
* It renders four calm states off the SINGLE existing `Derived` payload — no
|
||||
* new engine math, no new route, no schema change:
|
||||
* - insufficient (< 2 banded vitals) → "building your baselines" + coverage.
|
||||
* - all-clear (ok, !fired, 0 contributing) → a green check + the count
|
||||
* checked.
|
||||
* - watch (ok, !fired, 1 contributing) → a calm, non-alert line naming the
|
||||
* one vital.
|
||||
* - fired (ok, fired, ≥ 2 contributing) → an amber awareness card naming the
|
||||
* vitals + the load-bearing "possible factors — never a cause" line.
|
||||
* The card renders ONLY when there is an actual signal to show. A day where
|
||||
* every vital sits inside its personal range (all-clear), or an account still
|
||||
* building its baselines (insufficient), produces no card at all — the section
|
||||
* stays absent rather than surfacing an empty header. The two states that DO
|
||||
* render:
|
||||
* - watch (ok, 1 contributing vital, or a thin-history multi-signal flag) →
|
||||
* a calm, non-alert line naming the vital.
|
||||
* - fired (ok, fired, ≥ 2 contributing, with enough history) → an amber
|
||||
* awareness card naming the vitals + the load-bearing "possible factors —
|
||||
* never a cause" line.
|
||||
*
|
||||
* Restraint guarantees: at most the `warning` (amber) band — never
|
||||
* `destructive`/red, never a score, never a chart (so no Recharts), never a
|
||||
@@ -167,22 +165,10 @@ export function CoincidentDeviationCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Building the baselines — fewer than two banded vitals. Calm, never an
|
||||
// alarm, never blank.
|
||||
// Building the baselines — fewer than two banded vitals. There is no signal
|
||||
// to show, so the section stays absent rather than rendering an empty husk.
|
||||
if (data.status === "insufficient") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="insufficient" provenance={data.provenance}>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="coincident-building"
|
||||
>
|
||||
{t("insights.derived.coincident.building")}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
@@ -208,32 +194,10 @@ export function CoincidentDeviationCard({
|
||||
? "fired"
|
||||
: "watch";
|
||||
|
||||
// All clear — every vital is inside its personal range, so there is no signal
|
||||
// worth a card. Hide the whole section rather than surface an empty header.
|
||||
if (state === "all-clear") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="all-clear" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.allClear")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.allClearMeta", {
|
||||
count: v.vitals.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state === "watch") {
|
||||
|
||||
@@ -159,9 +159,9 @@ export function PeriodNarrativeCard({
|
||||
className={cn(SHELL, className)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
<h2 className="text-foreground truncate text-base font-semibold">
|
||||
{t("insights.narrativeTitle")}
|
||||
</span>
|
||||
</h2>
|
||||
{narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : null}
|
||||
|
||||
@@ -124,22 +124,31 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
className="w-60"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* R20 — the user header is the direct route into the account
|
||||
settings. Clicking the avatar / name jumps straight to
|
||||
`/settings/account` rather than making the user hunt for a
|
||||
separate "Settings" entry. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="flex cursor-pointer items-center gap-3 px-2 py-2"
|
||||
>
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
@@ -219,20 +228,29 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
return (
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* R20 — the avatar + name in the expanded footer routes straight
|
||||
into the account settings; the kebab to the right still opens
|
||||
the rest of the user menu. */}
|
||||
<Link
|
||||
href="/settings/account"
|
||||
aria-label={t("nav.accountSettings")}
|
||||
className="hover:bg-accent flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
|
||||
@@ -207,49 +207,18 @@ describe("medication cycle status — open-cycle line", () => {
|
||||
expect(html).not.toContain("Next dose in");
|
||||
});
|
||||
|
||||
it("on_track several days out reads the relative-day phrasing in success tone", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(4) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain("lucide-circle-check");
|
||||
});
|
||||
|
||||
it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => {
|
||||
// Over the wire `nextDueAt` is JSON, so it reaches the card as a string
|
||||
// even though the type says `Date`. The component must re-hydrate it for
|
||||
// the Berlin day-bucketing — a raw string would silently drift the count.
|
||||
const iso = inDays(4).toISOString() as unknown as Date;
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: iso }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
});
|
||||
|
||||
it("on_track one day out reads tomorrow", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(1) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose tomorrow");
|
||||
expect(html).not.toContain("in 1 days");
|
||||
});
|
||||
|
||||
it("on_track later today reads today", () => {
|
||||
// Six hours past the pinned noon — still the same Berlin calendar day.
|
||||
const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose today");
|
||||
it("on_track renders nothing — the next-dose timing already reads on the card's top slot", () => {
|
||||
// The calm on_track "next dose in N days" phrasing duplicates the
|
||||
// next-intake slot at the top of the card, so the open-cycle line stays
|
||||
// silent for an on-track med; only due / overdue earn a line here.
|
||||
for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("due reads the amber due-today line", () => {
|
||||
|
||||
@@ -370,15 +370,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
|
||||
it("renders the injection-site rotation nudge folded into the upcoming line", () => {
|
||||
// The rotation nudge surfaces only when `lastSite` and the recommender
|
||||
// disagree. We feed a left-cluster history → the recommender lands on a
|
||||
// right-side or thigh/arm site. The nudge is no longer a separate
|
||||
// bordered block (which made the GLP-1 card taller than the generic card
|
||||
// in a grid row); it is folded into the upcoming-injection line as a
|
||||
// "· Recommended next: …" suffix, so the card keeps the same row
|
||||
// inventory as the generic card. The last site stays visible on the
|
||||
// last-injection line.
|
||||
it("does not render an injection-site recommendation on the card", () => {
|
||||
// The card no longer carries a "recommended next site" nudge — the
|
||||
// recommendation is noise on the at-a-glance surface. Site TRACKING stays
|
||||
// (the last site reads on the last-injection line; the post-dose picker
|
||||
// still captures the next site), only the recommendation copy is gone.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -401,11 +397,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The recommended-next nudge renders in the upcoming line.
|
||||
expect(html).toContain("Recommended next:");
|
||||
// The standalone bordered rotation block is gone (height-neutral fold).
|
||||
expect(html).not.toContain("bg-muted/40 rounded-md border");
|
||||
// Last site stays visible on the last-injection line.
|
||||
// No recommendation copy anywhere on the card.
|
||||
expect(html).not.toContain("Recommended next:");
|
||||
// Last site stays visible on the last-injection line (tracking preserved).
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
|
||||
import { AlertTriangle, CalendarClock, CircleDashed } from "lucide-react";
|
||||
|
||||
import type { CurrentCycle } from "@/lib/analytics/compliance";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toBerlinDate } from "@/lib/medications/window-status";
|
||||
|
||||
interface MedicationCycleStatusProps {
|
||||
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
|
||||
cycle: CurrentCycle;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whole-day delta between two instants, measured on the user's calendar
|
||||
* (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
|
||||
* rather than a raw 48-hour count. Positive = future, 0 = today, negative =
|
||||
* past. Mirrors the day-bucketing the card already does for its next/last slot.
|
||||
*/
|
||||
function calendarDayDelta(target: Date, now: Date): number {
|
||||
const a = toBerlinDate(target);
|
||||
const b = toBerlinDate(now);
|
||||
const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
||||
return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.14.0 — the open-cycle status line for the medication cards.
|
||||
*
|
||||
@@ -32,15 +15,20 @@ function calendarDayDelta(target: Date, now: Date): number {
|
||||
* rolling med (a GLP-1 injection) that is simply between doses has no scored
|
||||
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
|
||||
* says nothing about whether the user is actually on schedule. This line is
|
||||
* decoupled from the rate: it reports the open cycle's calm status — when the
|
||||
* next dose lands, whether it's due or overdue, or that there's not enough
|
||||
* history to score yet.
|
||||
* decoupled from the rate: it reports the open cycle's calm status — whether
|
||||
* the dose is due or overdue, or that there's not enough history to score yet.
|
||||
*
|
||||
* The calm `on_track` "next dose in N days" phrasing is intentionally NOT
|
||||
* rendered here: the upcoming-dose timing already reads on the card's
|
||||
* next-intake slot at the top, so repeating it would be a duplicate. Only the
|
||||
* actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
|
||||
* this line.
|
||||
*
|
||||
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
|
||||
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
|
||||
* uses (success / warning / destructive) and pairs every tier with a Lucide
|
||||
* glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
|
||||
* It complements the compliance number; it does not replace it.
|
||||
* uses (warning / destructive) and pairs every tier with a Lucide glyph so
|
||||
* colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color). It
|
||||
* complements the compliance number; it does not replace it.
|
||||
*/
|
||||
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,6 +49,12 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// on_track — the next dose simply hasn't come round yet. The upcoming-dose
|
||||
// timing already reads on the card's next-intake slot at the top, so a second
|
||||
// "next dose in N days" line here is a duplicate. Render nothing for the
|
||||
// on-track case; only the actionable due / overdue tiers earn this line.
|
||||
if (cycle.state !== "missed" && cycle.state !== "due") return null;
|
||||
|
||||
let tone: string;
|
||||
let icon: React.ReactNode;
|
||||
let label: string;
|
||||
@@ -69,30 +63,11 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
tone = "text-destructive";
|
||||
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleOverdue");
|
||||
} else if (cycle.state === "due") {
|
||||
} else {
|
||||
// due
|
||||
tone = "text-warning";
|
||||
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleDueToday");
|
||||
} else {
|
||||
// on_track — the next dose simply hasn't come round yet.
|
||||
tone = "text-success";
|
||||
icon = <CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
const now = new Date();
|
||||
// `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
|
||||
// re-hydrate it so the Berlin day-bucketing (toLocaleString with a
|
||||
// timeZone) actually applies — a raw string would silently skip it and
|
||||
// drift the count by a day near midnight / off Berlin.
|
||||
const days = cycle.nextDueAt
|
||||
? calendarDayDelta(new Date(cycle.nextDueAt), now)
|
||||
: null;
|
||||
if (days === null || days <= 0) {
|
||||
// on_track with the slot today (now < dueAt but same calendar day).
|
||||
label = t("medications.cycleNextDoseToday");
|
||||
} else if (days === 1) {
|
||||
label = t("medications.cycleNextDoseTomorrow");
|
||||
} else {
|
||||
label = t("medications.cycleNextDoseInDays", { days });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,10 +29,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import {
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import {
|
||||
@@ -324,11 +321,6 @@ export function Glp1MedicationCard({
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
const recommendedNextSite = nextInjectionSite(
|
||||
recentInjections
|
||||
.map((r) => r.injectionSite)
|
||||
.filter(Boolean) as InjectionSiteKey[],
|
||||
);
|
||||
|
||||
function lastInjectionLabel(): string | null {
|
||||
if (!medication.lastTakenAt) return null;
|
||||
@@ -433,23 +425,6 @@ export function Glp1MedicationCard({
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
{/* Rotation nudge — folded into the upcoming-injection line
|
||||
instead of a separate bordered block, so the GLP-1 card
|
||||
keeps the same row inventory (and height) as the generic
|
||||
card. Only when we have a last + recommended site that
|
||||
differs from it. */}
|
||||
{lastSite &&
|
||||
recommendedNextSite &&
|
||||
recommendedNextSite !== lastSite && (
|
||||
<span className="hidden sm:inline">
|
||||
{" · "}
|
||||
{t("medications.glp1RotationSuggested", {
|
||||
site: t(
|
||||
`medications.site${siteSuffix(recommendedNextSite)}`,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -203,28 +203,35 @@ export function ApiTokensRow({
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.api.caption", { name: medicationName })}
|
||||
</p>
|
||||
{/* The raw endpoint URL is redundant with the copy button below, so
|
||||
it is rendered muted + single-line-truncated rather than as a
|
||||
full-width block — the long absolute URL was pure visual noise. */}
|
||||
<code
|
||||
className="text-muted-foreground bg-muted/60 block truncate rounded px-2 py-1 font-mono text-xs"
|
||||
title={endpoint}
|
||||
{/* R26 — the endpoint URL is an inline read-only field with a copy
|
||||
icon on the right rather than a separate "URL kopieren" button.
|
||||
The `<code>` carries the value; the icon button copies it. */}
|
||||
<div
|
||||
className="border-input bg-muted/60 flex items-center gap-2 rounded-md border px-2 py-1"
|
||||
data-slot="api-endpoint-url"
|
||||
>
|
||||
POST {endpoint}
|
||||
</code>
|
||||
<code
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs"
|
||||
title={endpoint}
|
||||
>
|
||||
POST {endpoint}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
void copy(endpoint, t("medications.detail.api.urlCopied"))
|
||||
}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label={t("medications.detail.api.copyUrl")}
|
||||
data-slot="api-tokens-copy-url"
|
||||
>
|
||||
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copy(endpoint, t("medications.detail.api.urlCopied"))}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="api-tokens-copy-url"
|
||||
>
|
||||
<Copy aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.api.copyUrl")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void mintToken()}
|
||||
disabled={busy}
|
||||
|
||||
@@ -201,7 +201,7 @@ export function NotificationsBody({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{status && status.channels.length > 0 && (
|
||||
{Array.isArray(status?.channels) && status.channels.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Arztbericht (doctor-report) card contract.
|
||||
*
|
||||
* v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
|
||||
* back to a small secondary card at the bottom of `/settings/export`
|
||||
* (the health-record export now owns the hero). The card stays fully
|
||||
* functional — only the framing and visual weight changed. This suite
|
||||
* pins the SSR contract:
|
||||
*
|
||||
* 1. The card renders with the expected `data-testid` slot.
|
||||
* 2. The value statement is present (Marc-Voice copy, no AI mention).
|
||||
* 3. The CTA button is present, clears the 44 px touch floor, and
|
||||
* carries an `aria-describedby` link to the value statement.
|
||||
* 4. The CTA is wired to a click handler (the prop `data-testid`
|
||||
* surface is the e2e contract).
|
||||
* 5. Both DE and EN locales resolve their copy without leaking the
|
||||
* raw i18n key.
|
||||
*
|
||||
* Project convention is SSR-only tests (no `@testing-library/react`).
|
||||
* The actual click-flow + PDF generation is exercised by the e2e
|
||||
* Playwright path and the dialog's own integration tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/export",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ArztberichtHeroCard } from "../arztbericht-hero-card";
|
||||
|
||||
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ArztberichtHeroCard> — SSR contract", () => {
|
||||
it("renders the card slot with eyebrow + title", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report"');
|
||||
// Title falls back to the existing doctorReport.title key — keeps
|
||||
// a single source-of-truth for the brand name.
|
||||
expect(html).toContain("Doctor Report");
|
||||
// Eyebrow surfaces the use-case framing.
|
||||
expect(html).toContain("Doctor visit");
|
||||
});
|
||||
|
||||
it("renders the value statement as a Marc-Voice one-liner", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-value"');
|
||||
expect(html).toContain("printable PDF report");
|
||||
// No AI/Coach name-drop in the user-facing artefact (Marc-Voice).
|
||||
expect(html.toLowerCase()).not.toContain("coach");
|
||||
expect(html.toLowerCase()).not.toContain(" ai ");
|
||||
});
|
||||
|
||||
it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
// 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
|
||||
// on mobile and falls back to the compact `sm:min-h-9` on pointer
|
||||
// devices.
|
||||
expect(html).toContain("min-h-11");
|
||||
// Aria-describedby links the CTA to the value statement so screen
|
||||
// readers announce the framing alongside the action label.
|
||||
expect(html).toMatch(/aria-describedby="[^"]+"/);
|
||||
});
|
||||
|
||||
it("does NOT leak raw i18n keys", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).not.toContain("settings.sections.export.hero");
|
||||
expect(html).not.toContain("settings.sections.export.cards.doctorReport");
|
||||
});
|
||||
|
||||
it("renders German copy when the DE locale is active", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />, "de");
|
||||
expect(html).toContain("Arztbericht");
|
||||
expect(html).toContain("Arzttermin");
|
||||
// Umlauts encoded as their literal UTF-8 character (not their HTML
|
||||
// entity), matching the rest of the codebase.
|
||||
expect(html).toContain("druckbarer PDF-Bericht");
|
||||
expect(html).not.toContain("settings.sections.export.hero");
|
||||
});
|
||||
|
||||
it("renders as a plain secondary card, not a hero", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
// v1.12 demoted the doctor-report from the page hero — the
|
||||
// health-record export now owns the hero treatment, so this card
|
||||
// must NOT carry the hero gradient/glow utilities.
|
||||
expect(html).not.toContain("hero-gradient");
|
||||
expect(html).not.toContain("glow-purple");
|
||||
// It renders with the standard secondary-card surface.
|
||||
expect(html).toContain("bg-card");
|
||||
});
|
||||
|
||||
it("does not paint dialog markup in the closed-state SSR pass", () => {
|
||||
// Radix Dialog renders its portal content lazily, so the closed
|
||||
// SSR pass never carries `role="dialog"`. The CTA testid IS
|
||||
// present so the e2e suite can drive the open from there. Pinning
|
||||
// this contract guards against an accidental `defaultOpen={true}`
|
||||
// regression that would briefly paint the dialog above the page on
|
||||
// every cold mount.
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
expect(html).not.toContain('role="dialog"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ArztberichtHeroCard> — heading hierarchy", () => {
|
||||
it("uses an <h2> so it nests cleanly below the page-level <h1>", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
// Page-level h1 (settings-section-export-title) wraps the section;
|
||||
// hero owns the h2 slot. The export-section test pins the outer
|
||||
// h1; this assertion pins the hero's contribution.
|
||||
expect(html).toMatch(/<h2[^>]*id="export-hero-doctor-report-title"/);
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,16 @@
|
||||
* - Medications CSV (with optional intake-history toggle)
|
||||
* - Mood CSV
|
||||
* - Full JSON Backup
|
||||
* - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
|
||||
*
|
||||
* Each card must surface a title, a 1-line description, and a
|
||||
* download/generate button. SSR-only smoke test — interaction is
|
||||
* exercised by the e2e suite.
|
||||
*
|
||||
* v1.12 — the health-record export takes the page hero; the
|
||||
* doctor-report card (`<ArztberichtHeroCard>`) is demoted to a small
|
||||
* secondary card at the bottom of the page. The four CSV/JSON tiles
|
||||
* stay under a "Weitere Export-Optionen" / "Other export options"
|
||||
* sub-heading. Each component owns its own contract test; this suite
|
||||
* pins the page-level shape.
|
||||
* The health-record export takes the page hero. The doctor-report PDF
|
||||
* now lives under the health-record export and is no longer offered as
|
||||
* a separate card here. The four CSV/JSON tiles stay under a "Weitere
|
||||
* Export-Optionen" / "Other export options" sub-heading. Each component
|
||||
* owns its own contract test; this suite pins the page-level shape.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -68,12 +66,11 @@ describe("<ExportSection> — SSR smoke", () => {
|
||||
expect(html).toContain("glow-purple");
|
||||
});
|
||||
|
||||
it("mounts the demoted doctor-report card", () => {
|
||||
it("no longer mounts a separate doctor-report card", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// The card owns its own contract suite; here we pin that the page
|
||||
// mounts it, and that the legacy in-grid doctor-report card is
|
||||
// gone. The card no longer carries the hero gradient/glow (v1.12).
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report"');
|
||||
// The doctor-report PDF now lives under the health-record export;
|
||||
// the Export page no longer offers it as a separate surface.
|
||||
expect(html).not.toContain('data-testid="export-hero-doctor-report"');
|
||||
expect(html).not.toContain('data-testid="export-card-doctor-report"');
|
||||
});
|
||||
|
||||
@@ -107,19 +104,16 @@ describe("<ExportSection> — SSR smoke", () => {
|
||||
expect(html).toContain('data-testid="export-medications-include-intake"');
|
||||
});
|
||||
|
||||
it("renders a download/generate button per secondary card + the hero CTA", () => {
|
||||
it("renders a download/generate button per secondary card", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// Four secondary cards each carry an `export-action-*` testid; the
|
||||
// hero owns the `export-hero-doctor-report-action` slot.
|
||||
// Four secondary cards each carry an `export-action-*` testid.
|
||||
const buttonMatches = html.match(/data-testid="export-action-/g);
|
||||
expect(buttonMatches?.length ?? 0).toBe(4);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
});
|
||||
|
||||
it("German locale renders the DE heading + hero copy", () => {
|
||||
const html = render(<ExportSection />, "de");
|
||||
expect(html).toContain("Export");
|
||||
expect(html).toContain("Arztbericht");
|
||||
expect(html).toContain("Weitere Export-Optionen");
|
||||
expect(html).not.toContain("settings.sections.export.");
|
||||
});
|
||||
|
||||
@@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP,
|
||||
// Fitbit / Google Health).
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
|
||||
// Exactly one pill per card → 3 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Card-body "letzter Sync" repetition is gone — no
|
||||
@@ -216,19 +216,12 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
});
|
||||
|
||||
it("Mood Log disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
|
||||
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
|
||||
setIntegrationStatus({
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: "2026-05-08T10:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-08T10:00:00.000Z",
|
||||
@@ -240,7 +233,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Not connected");
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Mood Log card no longer has a trailing "letzter Sync" line.
|
||||
// No trailing "letzter Sync" line outside the pill.
|
||||
expect(html).not.toContain("Last sync:");
|
||||
});
|
||||
|
||||
@@ -285,7 +278,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
expect(html).toContain("Withings activity error: 293");
|
||||
});
|
||||
|
||||
it("Mood Log card carries the visual divider Withings has (consistency)", () => {
|
||||
it("every integration card carries the visual divider (consistency)", () => {
|
||||
setIntegrationStatus({
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
@@ -300,25 +293,13 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
||||
hasActivityScope: true,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
lastError: null,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
|
||||
entryCount: 42,
|
||||
webhookSecret: "secret123",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Every integration card includes the section divider data-testid so the
|
||||
// header → body separation is visually consistent (Withings, moodLog,
|
||||
// WHOOP, Fitbit / Google Health).
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
|
||||
// header → body separation is visually consistent (Withings, WHOOP,
|
||||
// Fitbit / Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,20 @@ describe("<NotificationStatusCard />", () => {
|
||||
expect(html).not.toContain('data-testid="notification-status-list"');
|
||||
});
|
||||
|
||||
it("renders empty state instead of crashing when data is not an array", () => {
|
||||
// Regression: the status endpoint returns `{ channels, events }` where
|
||||
// `events` is an object map. A shape-drifted / poisoned cache read that
|
||||
// hands the whole object to the card must not reach `.map` on a
|
||||
// non-array and white-screen the notifications panel.
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { channels: [], events: {} } as unknown as MockChannel[],
|
||||
isLoading: false,
|
||||
});
|
||||
const html = render(<NotificationStatusCard />);
|
||||
expect(html).toContain("No channels configured yet");
|
||||
expect(html).not.toContain('data-testid="notification-status-list"');
|
||||
});
|
||||
|
||||
it("paints Active badge for a healthy channel", () => {
|
||||
setChannels([
|
||||
{
|
||||
|
||||
@@ -48,8 +48,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// IW-D merged it into `thresholds`.
|
||||
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
|
||||
// between `thresholds` (Targets) and `ai`.
|
||||
// v1.11.0 — `sharing` (clinician share links) added between `api` and
|
||||
// `export`. Section count: 12.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
@@ -59,7 +57,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
"about",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* v1.11.0 — Settings → Sharing (Epic C, C7).
|
||||
*
|
||||
* The owner share-link surface. The security-load-bearing guarantees the
|
||||
* test pins:
|
||||
* 1. The list never carries a raw `hls_` token — the server stores only the
|
||||
* hash, so the list response shape has no token field and the rendered
|
||||
* markup must not surface one.
|
||||
* 2. The one-time token reveal card (`share-token-reveal`) is absent until a
|
||||
* create succeeds — on first paint (no token in state) it must not render.
|
||||
* 3. Active links render with a revoke control; revoked/expired links fold
|
||||
* into the inactive list.
|
||||
* 4. The expiry input is bounded at the server cap (90 days).
|
||||
*
|
||||
* SSR-static render only (no jsdom), matching the sibling settings-section
|
||||
* tests. The TanStack hooks are mocked so the query returns a fixed link list.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const FIXTURE_LINKS = [
|
||||
{
|
||||
id: "link-active",
|
||||
label: "Cardiology referral",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
allowFhirApi: true,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastAccessAt: "2026-06-02T10:00:00.000Z",
|
||||
accessCount: 3,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: "link-revoked",
|
||||
label: "Old GP link",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
revokedAt: "2026-05-15T00:00:00.000Z",
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SharingSection } from "../sharing-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<SharingSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SharingSection> — owner share-link surface (C7)", () => {
|
||||
it("renders the create form with a 90-day-capped expiry input", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("New share link");
|
||||
// The expiry field carries the server's max-days cap.
|
||||
expect(html).toContain('max="90"');
|
||||
expect(html).toContain("Create link");
|
||||
});
|
||||
|
||||
it("does not reveal a token before a create succeeds", () => {
|
||||
const html = render();
|
||||
// The one-time reveal card only mounts once `newToken` state is set.
|
||||
expect(html).not.toContain('data-testid="share-token-reveal"');
|
||||
});
|
||||
|
||||
it("lists the active link with a revoke control and never a raw token", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="share-active-list"');
|
||||
expect(html).toContain("Cardiology referral");
|
||||
expect(html).toContain("Revoke");
|
||||
// The list response shape carries no token; the markup must not surface
|
||||
// an `hls_` string anywhere.
|
||||
expect(html).not.toContain("hls_");
|
||||
// The FHIR badge surfaces for the FHIR-enabled link.
|
||||
expect(html).toContain("FHIR");
|
||||
});
|
||||
|
||||
it("folds revoked links into the inactive list (not the active list)", () => {
|
||||
const html = render();
|
||||
// The active list must NOT contain the revoked link's label.
|
||||
const activeListStart = html.indexOf('data-testid="share-active-list"');
|
||||
const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
|
||||
// Inactive list is collapsed by default, so the label only appears once
|
||||
// the disclosure is open — but the active list must never carry it.
|
||||
const activeSlice =
|
||||
activeListEnd > activeListStart
|
||||
? html.slice(activeListStart, activeListEnd)
|
||||
: html.slice(activeListStart);
|
||||
expect(activeSlice).not.toContain("Old GP link");
|
||||
// The inactive-count disclosure surfaces.
|
||||
expect(html).toContain("Revoked and expired");
|
||||
});
|
||||
|
||||
it("renders localized section copy", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Neuer Freigabe-Link");
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings → Export Arztbericht (doctor-report) card.
|
||||
*
|
||||
* The doctor-report PDF is the artefact a user carries into a doctor's
|
||||
* appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
|
||||
* it back to a small secondary card at the bottom of the export page —
|
||||
* the health-record export now owns the page hero. The card is fully
|
||||
* functional: the CTA routes through the existing `<DoctorReportDialog>`
|
||||
* + `/api/doctor-report` flow exactly as before. Only the framing and
|
||||
* visual weight changed.
|
||||
*
|
||||
* Layout (compact card, no hero gradient):
|
||||
*
|
||||
* ┌─ card ────────────────────────────────────────────┐
|
||||
* │ [icon] H2 title eyebrow │
|
||||
* │ one-line value statement │
|
||||
* │ [Configure & generate] PDF · printable │
|
||||
* └───────────────────────────────────────────────────┘
|
||||
*
|
||||
* a11y:
|
||||
* - The CTA button clears the 44 px touch floor (`min-h-11`).
|
||||
* - `focus-visible:ring` ships via the default `<Button>` variant.
|
||||
* - The CTA is `aria-describedby={valueStatementId}` so a screen
|
||||
* reader announces the value statement alongside the action.
|
||||
* - The card's `<h2>` slots beneath the page-level `<h1>`.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Loader2, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DoctorReportDialog,
|
||||
type DoctorReportSubmitPayload,
|
||||
} from "@/components/doctor-report/doctor-report-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function ArztberichtHeroCard() {
|
||||
const { t, locale } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const valueStatementId = useId();
|
||||
|
||||
// Pre-fill the practice name from the user profile so the dialog opens
|
||||
// ready-to-submit. Best-effort: if the request fails, the dialog still
|
||||
// works without a pre-fill — mirrors the legacy `<DoctorReportCard>`.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => {
|
||||
if (cancelled || !json) return;
|
||||
const name = json?.data?.lastReportPracticeName;
|
||||
if (typeof name === "string") setDefaultPracticeName(name);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore — pre-fill is a UX nicety
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(payload: DoctorReportSubmitPayload) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/doctor-report", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
startDate: payload.startDate,
|
||||
endDate: payload.endDate,
|
||||
practiceName: payload.practiceName,
|
||||
sections: payload.sections,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`${res.status}`);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
const { generateDoctorReportPDF } =
|
||||
await import("@/lib/doctor-report-pdf");
|
||||
const doc = generateDoctorReportPDF(json.data, { t, locale });
|
||||
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
|
||||
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
if (payload.practiceName) setDefaultPracticeName(payload.practiceName);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="export-hero-doctor-report"
|
||||
aria-labelledby="export-hero-doctor-report-title"
|
||||
className="bg-card border-border rounded-xl border p-5 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope
|
||||
className="text-primary h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2
|
||||
id="export-hero-doctor-report-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.cards.doctorReport.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
|
||||
{t("settings.sections.export.hero.eyebrow")}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
id={valueStatementId}
|
||||
data-testid="export-hero-doctor-report-value"
|
||||
className="text-muted-foreground max-w-2xl text-xs leading-relaxed"
|
||||
>
|
||||
{t("settings.sections.export.hero.valueStatement")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
data-testid="export-hero-doctor-report-action"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
|
||||
// we keep it on mobile and fall back to the compact
|
||||
// `sm:min-h-9` on pointer devices so a future className
|
||||
// override can't re-lift the mobile minimum below the
|
||||
// touch-target contract.
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-describedby={valueStatementId}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sections.export.hero.cta")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.hero.formatHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DoctorReportDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
defaultPracticeName={defaultPracticeName}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,11 @@
|
||||
* 3. Medications CSV — optional intake-history toggle
|
||||
* 4. Mood CSV — optional `since`/`until`
|
||||
* 5. Full JSON Backup — single-file user-scoped dump
|
||||
* 6. Doctor Report (PDF) — small secondary card at the bottom
|
||||
* (`<ArztberichtHeroCard>`)
|
||||
*
|
||||
* v1.12 — the health-record export is the headline "data out" path; it
|
||||
* takes the page hero. The doctor-report PDF is demoted to a small
|
||||
* secondary card at the bottom of the page — fully functional, just no
|
||||
* longer the flagship surface it was in v1.4.37 W7a.
|
||||
* takes the page hero. The doctor-report PDF lives under the
|
||||
* health-record export now and is no longer offered as a separate card
|
||||
* here (it used to be a small secondary card at the bottom).
|
||||
*
|
||||
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
|
||||
*/
|
||||
@@ -42,7 +40,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ArztberichtHeroCard } from "@/components/settings/arztbericht-hero-card";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -114,11 +111,6 @@ export function ExportSection() {
|
||||
<FullBackupCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* v1.12 — doctor-report PDF, demoted from the former page hero to a
|
||||
small secondary card at the bottom. Fully functional — the same
|
||||
`<DoctorReportDialog>` + `/api/doctor-report` flow runs from here. */}
|
||||
<ArztberichtHeroCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export function HealthRecordExportPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Smile,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -42,14 +41,6 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
interface GlobalServiceAvailability {
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
apiGlobal: boolean;
|
||||
moodLogGlobal: boolean;
|
||||
}
|
||||
|
||||
// v1.4.15 Phase B2: shared status payload for both integration cards.
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
// IntegrationStatusPill now owns state + last-sync presentation, and
|
||||
@@ -183,23 +174,11 @@ export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: queryKeys.settingsGlobalServices(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/global-services");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as GlobalServiceAvailability;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||
|
||||
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
const moodLogViewModel = pickStatus(integrationStatus, "moodlog");
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -221,7 +200,6 @@ export function IntegrationsSection() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1545,354 +1523,3 @@ function FitbitCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoodLogCard({
|
||||
viewModel,
|
||||
}: {
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [url, setUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
// v1.12.1 — read off the consolidated /api/integrations/status
|
||||
// envelope; the per-card /api/integrations/moodlog/status round-trip
|
||||
// is gone. Mutations invalidate the envelope so the card's webhook
|
||||
// secret / entry-count / last-sync repaint on the next refetch.
|
||||
const status = viewModel;
|
||||
const refreshStatus = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
const res = await fetch("/api/settings/moodlog", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.saved"));
|
||||
setMsgType("success");
|
||||
setUrl("");
|
||||
setApiKey("");
|
||||
void refreshStatus();
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/moodlog/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setMsg(
|
||||
t("settings.moodLogSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setMsgType("success");
|
||||
void refreshStatus();
|
||||
} else {
|
||||
setMsg(t("settings.moodLogSyncFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("settings.moodLogSyncFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
const res = await fetch("/api/settings/moodlog", { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.moodLogDisconnected"));
|
||||
setMsgType("success");
|
||||
void refreshStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Withings logic: the pill state comes from the cross-
|
||||
// integration envelope, which also carries `legacyLastSyncedAt`
|
||||
// (the user's moodLog last-sync timestamp) so a manual sync paints
|
||||
// a fresh "X min ago" once the envelope refetches.
|
||||
const pillState: IntegrationPillState = status?.configured
|
||||
? pillStateFor(viewModel)
|
||||
: "disconnected";
|
||||
const pillLastSyncAt =
|
||||
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
|
||||
const errorMessage =
|
||||
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Smile}
|
||||
title={
|
||||
<a
|
||||
href="https://moodlog.onback.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{t("settings.moodLogTitle")}
|
||||
</a>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p>{t("settings.moodLogDescription")}</p>
|
||||
<p className="text-muted-foreground/80">
|
||||
{t("settings.moodLogDeprecated")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
status={
|
||||
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* v1.4.19 A5 — visual divider matches Withings for consistency
|
||||
(the maintainer explicitly called the asymmetry out). */}
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
|
||||
<Input
|
||||
id="moodlog-url"
|
||||
type="url"
|
||||
placeholder={t("settings.moodLogUrlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="moodlog-api-key">
|
||||
{t("settings.moodLogApiKey")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="moodlog-api-key"
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
: t("settings.moodLogApiKeyPlaceholder")
|
||||
}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
{/* v1.4.33 — save-button placement contract:
|
||||
right-aligned primary `Speichern` with the secondary
|
||||
`Verbindung testen` immediately to its left. Mood Log
|
||||
used to left-align its row, which was the only
|
||||
integration form that did. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/moodlog/test"
|
||||
disabled={!status?.configured}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || (!url.trim() && !apiKey.trim())}
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status?.configured && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{status.webhookSecret && (
|
||||
<div>
|
||||
<Label htmlFor="moodlog-webhook-secret">
|
||||
{t("settings.moodLogWebhookSecret")}
|
||||
</Label>
|
||||
{/* v1.4.33 — the webhook secret is a long hex string;
|
||||
on a 393 CSS px viewport the input + Copy button
|
||||
used to push past the card edge and trigger a body
|
||||
horizontal scroll. Stack vertically on `<sm` and
|
||||
break the input value so it wraps inside its own
|
||||
box. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
id="moodlog-webhook-secret"
|
||||
value={status.webhookSecret}
|
||||
readOnly
|
||||
className="font-mono text-xs break-all"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(status.webhookSecret!);
|
||||
setMsg(t("common.copied"));
|
||||
setMsgType("success");
|
||||
}}
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
|
||||
common.copy key instead of stripping the
|
||||
exclamation off common.copied. */}
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.moodLogWebhookSecretHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.19 A5 — The "letzter Sync" line that used to
|
||||
live here is gone. The pill in the card header
|
||||
already carries that information; repeating it
|
||||
inside the body is the redundancy the maintainer flagged. The
|
||||
entry-count is the only number we still surface
|
||||
because it's an integration-specific datapoint the
|
||||
pill cannot convey. */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>
|
||||
{t("settings.moodLogEntries")}: {status.entryCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet
|
||||
overflowed the card edge on a 393 CSS px viewport
|
||||
(~376 px of buttons + gaps inside 345 px of inner
|
||||
width). Match the Withings card pattern: `flex-wrap`
|
||||
+ `min-w-[10rem]` on `<sm` so the triggers stack two
|
||||
per row and fill their column cleanly; tablet+ keeps
|
||||
the inline row. */}
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={syncing}
|
||||
onClick={() => handleSync(false)}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.moodLogSync")}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("settings.moodLogFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.moodLogFullSyncTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.moodLogFullSyncDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.moodLogFullSyncConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{/* v1.4.3: aligned with the Withings disconnect button —
|
||||
outline + text-destructive instead of solid destructive
|
||||
so the trigger reads as a reversible action; the actual
|
||||
"yes, disconnect" confirmation inside the dialog keeps
|
||||
its solid-red treatment. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="mr-1 h-3.5 w-3.5" />
|
||||
{t("settings.moodLogDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.moodLogDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.moodLogDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect}>
|
||||
{t("settings.moodLogDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-success"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function MoodReminderCard({
|
||||
});
|
||||
|
||||
const enabled = optimistic ?? profile?.moodReminderEnabled ?? false;
|
||||
const reminderHour = optimisticHour ?? prefs?.mood.reminderHour ?? 22;
|
||||
const reminderHour = optimisticHour ?? prefs?.mood?.reminderHour ?? 22;
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setOptimistic(next);
|
||||
|
||||
@@ -119,7 +119,12 @@ export function NotificationStatusCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const channels = data ?? [];
|
||||
// Defensive: the status endpoint returns `{ channels, events }` where
|
||||
// `events` is an object map, not an array. A stale or shape-drifted
|
||||
// payload (or a cache read that kept the whole object instead of the
|
||||
// `.channels` slice) must never reach `.map` on a non-array — guard the
|
||||
// type here rather than white-screening the whole notifications panel.
|
||||
const channels = Array.isArray(data) ? data : [];
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
@@ -40,7 +39,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
sharing: Share2,
|
||||
export: Download,
|
||||
advanced: Settings2,
|
||||
about: Info,
|
||||
|
||||
@@ -22,10 +22,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
// v1.11.0 — `sharing` owns clinician share links: a time-boxed, scope-
|
||||
// frozen read-only view of the owner's record, optionally a scoped FHIR
|
||||
// face. Sits next to `api` (the other "hand a credential out" surface).
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
"about",
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
@@ -110,11 +109,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
slug: "sharing",
|
||||
titleKey: "settings.sections.sharing.title",
|
||||
icon: Share2,
|
||||
},
|
||||
{
|
||||
slug: "export",
|
||||
titleKey: "settings.sections.export.title",
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.11.0 — Settings → Sharing (Epic C, C7).
|
||||
*
|
||||
* The OWNER surface for clinician share links. A share link is a time-boxed,
|
||||
* scope-frozen, read-only view of the owner's own health record at
|
||||
* `/c/<token>`, optionally exposing a scoped read-only FHIR face. The raw
|
||||
* `hls_` token is shown EXACTLY ONCE on create — the list never carries it
|
||||
* (the server only stores its hash), so the copy-on-create card is the single
|
||||
* chance to capture it.
|
||||
*
|
||||
* Client island: the create form, the active/revoked lists, and the revoke
|
||||
* action all need state and mutate via the C4 lifecycle API
|
||||
* (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key
|
||||
* comes from the centralised factory. No markdown anywhere — every value
|
||||
* renders as escaped React text.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
|
||||
const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
|
||||
interface ShareLinkSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
lastAccessAt: string | null;
|
||||
accessCount: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function SharingSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-sharing-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sharing-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.sharing.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sharing.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ShareLinksCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isoDaysFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
function ShareLinksCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
|
||||
const [allowFhirApi, setAllowFhirApi] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
|
||||
const { data: links } = useQuery({
|
||||
queryKey: queryKeys.shareLinks(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/share-links");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data.shareLinks as ShareLinkSummary[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = label.trim();
|
||||
// Surface the same expiry-bound the server enforces before the round
|
||||
// trip, so the validation feedback is immediate.
|
||||
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
|
||||
throw new Error("EXPIRY_RANGE");
|
||||
}
|
||||
const res = await fetch("/api/share-links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
label: trimmed,
|
||||
rangeStart: isoDaysFromNow(-rangeDays),
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || "FAILED");
|
||||
}
|
||||
return json.data as ShareLinkSummary & { token: string };
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
setNewToken(created.token);
|
||||
setCopied(false);
|
||||
setLabel("");
|
||||
setFormError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setNewToken(null);
|
||||
setFormError(
|
||||
err.message === "EXPIRY_RANGE"
|
||||
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
|
||||
: err.message === "FAILED" || err.message === ""
|
||||
? t("common.error")
|
||||
: err.message,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/share-links/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
});
|
||||
|
||||
function toggleResourceType(type: ResourceType) {
|
||||
setResourceTypes((prev) =>
|
||||
prev.includes(type)
|
||||
? prev.filter((r) => r !== type)
|
||||
: [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
await navigator.clipboard.writeText(`${origin}/c/${newToken}`);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// Clipboard can be unavailable (insecure context); the token stays
|
||||
// visible in the card so the owner can copy it by hand.
|
||||
}
|
||||
}
|
||||
|
||||
const activeLinks = useMemo(
|
||||
() => (links ?? []).filter((l) => l.active),
|
||||
[links],
|
||||
);
|
||||
const inactiveLinks = useMemo(
|
||||
() => (links ?? []).filter((l) => !l.active),
|
||||
[links],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
|
||||
<Input
|
||||
id="share-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("settings.sharing.labelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
|
||||
<Input
|
||||
id="share-range"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={rangeDays}
|
||||
onChange={(e) =>
|
||||
setRangeDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.rangeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-expiry">
|
||||
{t("settings.sharing.expiry")}
|
||||
</Label>
|
||||
<Input
|
||||
id="share-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
value={expiryDays}
|
||||
onChange={(e) =>
|
||||
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5 pr-3">
|
||||
<Label htmlFor="share-fhir" className="text-sm font-medium">
|
||||
{t("settings.sharing.fhirApi")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.fhirApiHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-fhir"
|
||||
checked={allowFhirApi}
|
||||
onCheckedChange={setAllowFhirApi}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allowFhirApi && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharing.resourceTypes")}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{RESOURCE_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
htmlFor={`share-rt-${type}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`share-rt-${type}`}
|
||||
checked={resourceTypes.includes(type)}
|
||||
onCheckedChange={() => toggleResourceType(type)}
|
||||
/>
|
||||
<span className="font-mono text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
|
||||
data-testid="share-token-reveal"
|
||||
>
|
||||
<p className="text-success font-medium">
|
||||
{t("settings.sharing.tokenCreated")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.tokenOnce")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToken}
|
||||
aria-label={t("settings.sharing.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.sharing.activeTitle")}
|
||||
</h3>
|
||||
{activeLinks.length === 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm"
|
||||
data-testid="share-active-empty"
|
||||
>
|
||||
{t("settings.sharing.noActive")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="share-active-list">
|
||||
{activeLinks.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-words">
|
||||
{link.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.sharing.statusActive")}
|
||||
</Badge>
|
||||
{link.allowFhirApi && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
FHIR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.created")}:
|
||||
</span>{" "}
|
||||
{formatDate(link.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.expires")}:
|
||||
</span>{" "}
|
||||
{formatDateTime(link.expiresAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
{link.lastAccessAt
|
||||
? ` · ${formatDateTime(link.lastAccessAt)}`
|
||||
: ""}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.revoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.sharing.revokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inactiveLinks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevoked((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("settings.sharing.inactiveTitle", {
|
||||
count: inactiveLinks.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevoked && (
|
||||
<ul className="space-y-2" data-testid="share-inactive-list">
|
||||
{inactiveLinks.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-words">
|
||||
{link.label}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,20 +37,40 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
|
||||
// to 40 px on sm+ where the pointer precision is higher.
|
||||
//
|
||||
// Chevron symmetry: `appearance-none` drops the browser-drawn dropdown arrow,
|
||||
// whose right inset is OS-controlled and reads visibly farther from the edge
|
||||
// than the value text sits from the left. We paint our own chevron as a
|
||||
// background SVG positioned `right 0.75rem center` (12 px) so its inset mirrors
|
||||
// the left text padding (`px-3`) — matching the shadcn `<SelectTrigger>`.
|
||||
// Doing it as a background (rather than an overlay element) keeps `<select>` a
|
||||
// single element, so every call site's width / margin className still composes
|
||||
// straight onto it. `pe-9` reserves room so a long value never runs under the
|
||||
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws,
|
||||
// at `currentColor` with 50 % opacity to match the trigger.
|
||||
const CHEVRON_SVG =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' opacity='0.5'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
|
||||
|
||||
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
export const NativeSelect = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
NativeSelectProps
|
||||
>(function NativeSelect({ className, children, ...props }, ref) {
|
||||
>(function NativeSelect({ className, children, style, ...props }, ref) {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
data-slot="native-select"
|
||||
className={cn(NATIVE_SELECT_CLASS, className)}
|
||||
style={
|
||||
{
|
||||
"--native-select-chevron": CHEVRON_SVG,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -56,7 +56,14 @@ function SelectTrigger({
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
{/* `-me-0.5` (-2 px end margin) optically aligns the chevron with the
|
||||
left text inset. The trigger box is `px-3` (12 px both sides), but
|
||||
the Lucide chevron glyph carries ~2 px of empty space inside its
|
||||
16 px bounding box, so the *visible* stroke landed ~2 px farther
|
||||
from the right edge than the value text sits from the left — the
|
||||
trigger read lopsided. Pulling the icon box 2 px toward the edge
|
||||
makes the visible chevron and the value text inset symmetric. */}
|
||||
<ChevronDownIcon className="-me-0.5 size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
/**
|
||||
* Client-side PDF generation for doctor reports.
|
||||
*
|
||||
* Every string and every number/date is driven by an injected `t()` and
|
||||
* locale-aware formatters so the exported PDF matches the user's UI language.
|
||||
* Call sites must pass `{ t, locale }` (use the ones from `useTranslations()`
|
||||
* / `useFormatters()`).
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
|
||||
|
||||
interface ReportData {
|
||||
period: { days: number; since: string; start?: string; end?: string };
|
||||
patient: {
|
||||
username: string | null;
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
};
|
||||
/** Free-text practice / clinic name printed on the cover. Optional. */
|
||||
practiceName?: string | null;
|
||||
stats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
>;
|
||||
/** Per-context glucose stats (canonical mg/dL). */
|
||||
glucoseStats?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
| { avg: number; min: number; max: number; count: number; latest: number }
|
||||
| undefined
|
||||
>;
|
||||
/** Display-unit preference: "mg/dL" (default) or "mmol/L". */
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
/** Per-context custom range (canonical mg/dL); fall back to defaults. */
|
||||
glucoseRanges?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ min: number; max: number } | undefined
|
||||
>;
|
||||
bmi: number | null;
|
||||
compliance: Record<
|
||||
string,
|
||||
{ total: number; taken: number; skipped: number; missed: number }
|
||||
>;
|
||||
medications: Array<{
|
||||
name: string;
|
||||
dose: string;
|
||||
schedules: Array<{
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
}>;
|
||||
}>;
|
||||
mood: {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
distribution: Record<number, number>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface DoctorReportOptions {
|
||||
t: T;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||||
};
|
||||
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
};
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
2: "doctorReport.moodBad",
|
||||
3: "doctorReport.moodNeutral",
|
||||
4: "doctorReport.moodGood",
|
||||
5: "doctorReport.moodGreat",
|
||||
};
|
||||
|
||||
function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
if (bmi < 25) return "doctorReport.bmiNormal";
|
||||
if (bmi < 30) return "doctorReport.bmiOverweight";
|
||||
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
|
||||
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
|
||||
return "doctorReport.bmiObeseGrade3";
|
||||
}
|
||||
|
||||
function getBpClassificationKey(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
|
||||
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
|
||||
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
|
||||
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
|
||||
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
|
||||
return "doctorReport.bpHypertensionGrade3";
|
||||
}
|
||||
|
||||
export function generateDoctorReportPDF(
|
||||
data: ReportData,
|
||||
options: DoctorReportOptions,
|
||||
): jsPDF {
|
||||
const { t, locale } = options;
|
||||
const formatters = makeFormatters(locale);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
|
||||
const unitFor = (type: string): string => {
|
||||
// Map entry === null means the unit needs translation (e.g. ACTIVITY_STEPS).
|
||||
const staticUnit = TYPE_UNIT_KEYS[type];
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return t("doctorReport.unitSteps");
|
||||
}
|
||||
return staticUnit ?? "";
|
||||
};
|
||||
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 20;
|
||||
let y = margin;
|
||||
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.title"), margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
// Practice / clinic name (optional). Rendered prominently above the
|
||||
// separator so the addressee is visible at first glance.
|
||||
if (data.practiceName) {
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(
|
||||
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
doc.setFont("helvetica", "normal");
|
||||
}
|
||||
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(margin, y, pageWidth - margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const patientInfo: string[] = [];
|
||||
if (data.patient.username) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
|
||||
}
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||||
);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
const genderKeys: Record<string, string> = {
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey =
|
||||
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
|
||||
);
|
||||
}
|
||||
// Reporting period uses explicit start/end when available; falls back to
|
||||
// (since, now()) for legacy payloads.
|
||||
const periodStart = data.period.start ?? data.period.since;
|
||||
const periodEnd = data.period.end ?? new Date().toISOString();
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`,
|
||||
);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`,
|
||||
);
|
||||
|
||||
for (const line of patientInfo) {
|
||||
doc.text(line, margin, y);
|
||||
y += 4.5;
|
||||
}
|
||||
y += 4;
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const vitalRows: string[][] = [];
|
||||
const vitalTypes = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
];
|
||||
|
||||
for (const type of vitalTypes) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
vitalRows.push([
|
||||
t(TYPE_LABEL_KEYS[type] ?? ""),
|
||||
`${num(s.latest)} ${unit}`.trim(),
|
||||
`${num(s.avg)} ${unit}`.trim(),
|
||||
num(s.min),
|
||||
num(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
// Per-context glucose rows (one per logged context). Values stored
|
||||
// canonically in mg/dL — convert to the user's display unit. Reference
|
||||
// ranges come from getEffectiveRange() server-side via data.glucoseRanges,
|
||||
// falling back to ADA defaults below.
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
const glucoseLabelKeys = {
|
||||
FASTING: "doctorReport.typeGlucoseFasting",
|
||||
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
|
||||
RANDOM: "doctorReport.typeGlucoseRandom",
|
||||
BEDTIME: "doctorReport.typeGlucoseBedtime",
|
||||
} as const;
|
||||
const defaultGlucoseRanges = {
|
||||
FASTING: { min: 70, max: 99 },
|
||||
POSTPRANDIAL: { min: 70, max: 140 },
|
||||
RANDOM: { min: 70, max: 140 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
} as const;
|
||||
const glucoseContexts: Array<keyof typeof glucoseLabelKeys> = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
for (const ctx of glucoseContexts) {
|
||||
const s = data.glucoseStats?.[ctx];
|
||||
if (!s) continue;
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
vitalRows.push([
|
||||
t(glucoseLabelKeys[ctx]),
|
||||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colParameter"),
|
||||
t("doctorReport.colCurrent"),
|
||||
t("doctorReport.colAvgPeriod"),
|
||||
t("doctorReport.colMin"),
|
||||
t("doctorReport.colMax"),
|
||||
t("doctorReport.colN"),
|
||||
],
|
||||
],
|
||||
body: vitalRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||||
if (sysStat && diaStat) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
|
||||
doc.text(
|
||||
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
if (data.bmi) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
t("doctorReport.bmiRow", {
|
||||
bmi: num(data.bmi, 1),
|
||||
class: t(getBmiClassificationKey(data.bmi)),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// Glucose classification block — one line per logged context with the
|
||||
// reference range and an in/out classification. Range source: server-side
|
||||
// getEffectiveRange() (data.glucoseRanges). Falls back to ADA defaults.
|
||||
const loggedGlucose = glucoseContexts.filter(
|
||||
(ctx) => data.glucoseStats?.[ctx],
|
||||
);
|
||||
if (loggedGlucose.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const ctx of loggedGlucose) {
|
||||
const s = data.glucoseStats![ctx]!;
|
||||
const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
const inRange = s.avg >= range.min && s.avg <= range.max;
|
||||
const classKey = inRange
|
||||
? "doctorReport.glucoseInTarget"
|
||||
: s.avg < range.min
|
||||
? "doctorReport.glucoseBelowTarget"
|
||||
: "doctorReport.glucoseAboveTarget";
|
||||
doc.text(
|
||||
t("doctorReport.glucoseRow", {
|
||||
label: t(glucoseLabelKeys[ctx]),
|
||||
avg: num(conv(s.avg), 1),
|
||||
unit: glucoseUnit,
|
||||
rangeMin: num(conv(range.min)),
|
||||
rangeMax: num(conv(range.max)),
|
||||
class: t(classKey),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 5;
|
||||
}
|
||||
y += 3;
|
||||
}
|
||||
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
String(c.skipped),
|
||||
String(c.missed),
|
||||
String(c.total),
|
||||
rate,
|
||||
];
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMedication"),
|
||||
t("doctorReport.colTaken"),
|
||||
t("doctorReport.colSkipped"),
|
||||
t("doctorReport.colMissed"),
|
||||
t("doctorReport.colTotal"),
|
||||
t("doctorReport.colComplianceRate"),
|
||||
],
|
||||
],
|
||||
body: compRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
if (data.mood) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.moodTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
t("doctorReport.moodSummary", {
|
||||
avg: num(data.mood.avg, 1),
|
||||
count: data.mood.count,
|
||||
min: num(data.mood.min, 0),
|
||||
max: num(data.mood.max, 0),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
|
||||
const distRows = Object.entries(data.mood.distribution).map(
|
||||
([score, count]) => [
|
||||
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
|
||||
String(count),
|
||||
data.mood!.count > 0
|
||||
? `${num((count / data.mood!.count) * 100, 1)}%`
|
||||
: "0%",
|
||||
],
|
||||
);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMood"),
|
||||
t("doctorReport.colCount"),
|
||||
t("doctorReport.colShare"),
|
||||
],
|
||||
],
|
||||
body: distRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
const addFooter = (pageDoc: jsPDF) => {
|
||||
const pageHeight = pageDoc.internal.pageSize.getHeight();
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(new Date()),
|
||||
}),
|
||||
margin,
|
||||
pageHeight - 6,
|
||||
);
|
||||
};
|
||||
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
addFooter(doc);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
Reference in new issue
Block a user