diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc8523b8..e2f97539c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9c9056393..723d7ac7c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.5 + version: 1.15.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts deleted file mode 100644 index 1a2bc3378..000000000 --- a/e2e/doctor-report.spec.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index d38f04aff..c468dd08c 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -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 }); } diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 899c0a183..5a7c79457 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -15,11 +15,10 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * verifies neither path leaks into a horizontal scrollbar on the * 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( diff --git a/messages/de.json b/messages/de.json index bed6128bc..3635e3cc5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index d9d9a08ba..5685bbc0f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 2629b9c90..2621b9d2e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index f352bd636..a77dc2824 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index a9325f736..f4c0652b1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index 1e6fe6c4a..474e3ccac 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", diff --git a/package.json b/package.json index e7f88ebef..d6cc575fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/seed-demo-apps01.sql b/scripts/seed-demo-apps01.sql new file mode 100644 index 000000000..b5b3ce4de --- /dev/null +++ b/scripts/seed-demo-apps01.sql @@ -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 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'); diff --git a/scripts/seed-demo-extend-1y.sql b/scripts/seed-demo-extend-1y.sql new file mode 100644 index 000000000..d611aea68 --- /dev/null +++ b/scripts/seed-demo-extend-1y.sql @@ -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 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'; diff --git a/scripts/seed-demo-scores-1y.sql b/scripts/seed-demo-scores-1y.sql new file mode 100644 index 000000000..d608b39b5 --- /dev/null +++ b/scripts/seed-demo-scores-1y.sql @@ -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 = ':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 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.) diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 1210e2074..38e5fadae 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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: () => , api: ApiSection, - sharing: SharingSection, export: ExportSection, advanced: AdvancedSection, }; diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index d4fb07e8e..f964d32ad 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -115,18 +115,14 @@ describe("", () => { 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(); - 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("", () => { }), ); const html = render(); - 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", () => { diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index cd427fffa..e7b4ff005 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -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 ( -
- -

- {t("insights.derived.coincident.building")} -

- -
-
- ); + 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 ( -
- -
-
-
-
- ); + return null; } if (state === "watch") { diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 98e3a46a2..7f2a53c15 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -159,9 +159,9 @@ export function PeriodNarrativeCard({ className={cn(SHELL, className)} >
- +

{t("insights.narrativeTitle")} - +

{narrative?.provenance ? ( ) : null} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4e62bf571..ba4cf3670 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -124,22 +124,31 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { className="w-60" sideOffset={8} > -
- - {avatarUrl && } - - {getInitials(user.username)} - - -
-

{user.username}

- {user.email && ( -

- {user.email} -

- )} -
-
+ {/* 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. */} + + + + {avatarUrl && } + + {getInitials(user.username)} + + +
+

{user.username}

+ {user.email && ( +

+ {user.email} +

+ )} +
+ +
@@ -219,20 +228,29 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { return (
- - {avatarUrl && } - - {getInitials(user.username)} - - -
-

{user.username}

- {user.email && ( -

- {user.email} -

- )} -
+ {/* 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. */} + + + {avatarUrl && } + + {getInitials(user.username)} + + +
+

{user.username}

+ {user.email && ( +

+ {user.email} +

+ )} +
+ { expect(html).not.toContain("Next dose in"); }); - it("on_track several days out reads the relative-day phrasing in success tone", () => { - const html = render( - , - ); - 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( - , - ); - expect(html).toContain("Next dose in 4 days"); - }); - - it("on_track one day out reads tomorrow", () => { - const html = render( - , - ); - 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( - , - ); - 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( + , + ); + expect(html).toBe(""); + } }); it("due reads the amber due-today line", () => { diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index a7130b2f0..c77f983ea 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -370,15 +370,11 @@ describe(" — 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(" — 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"); }); diff --git a/src/components/medications/card-parts/medication-cycle-status.tsx b/src/components/medications/card-parts/medication-cycle-status.tsx index 8ff46e633..c364b379f 100644 --- a/src/components/medications/card-parts/medication-cycle-status.tsx +++ b/src/components/medications/card-parts/medication-cycle-status.tsx @@ -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 =
- - - - ); -} diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 4330603aa..c4d0be2a4 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -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 - * (``) * * 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`. */ @@ -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() {
- - {/* v1.12 — doctor-report PDF, demoted from the former page hero to a - small secondary card at the bottom. Fully functional — the same - `` + `/api/doctor-report` flow runs from here. */} - ); } diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index cefedf981..54fe6e37c 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -433,7 +433,7 @@ export function HealthRecordExportPanel() {

)} -
+
); } - -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(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 ( -
- - {t("settings.moodLogTitle")} - - } - description={ - <> -

{t("settings.moodLogDescription")}

-

- {t("settings.moodLogDeprecated")} -

- - } - status={ - - } - /> - - {/* v1.4.19 A5 — visual divider matches Withings for consistency - (the maintainer explicitly called the asymmetry out). */} -
- -
- {errorMessage && } - -
-
- - setUrl(e.target.value)} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
-
- - setApiKey(e.target.value)} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
- {/* 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. */} -
- - -
-
- - {status?.configured && ( -
- {status.webhookSecret && ( -
- - {/* 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 ` - - -
-

- {t("settings.moodLogWebhookSecretHelp")} -

-
- )} - - {/* 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. */} -
- - {t("settings.moodLogEntries")}: {status.entryCount} - -
- - {/* 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 ` - - - - - - - - - - {t("settings.moodLogFullSyncTitle")} - - - {t("settings.moodLogFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.moodLogFullSyncConfirm")} - - - - - - - - {/* 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. */} - - - - - - {t("settings.moodLogDisconnectTitle")} - - - {t("settings.moodLogDisconnectDescription")} - - - - {t("common.cancel")} - - {t("settings.moodLogDisconnect")} - - - - -
-
- )} - - {msg && ( -

- {msg} -

- )} -
-
- ); -} diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 7d1340400..d4d7863dd 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -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); diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx index fca8276c1..b0609ac8e 100644 --- a/src/components/settings/notification-status-card.tsx +++ b/src/components/settings/notification-status-card.tsx @@ -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 (
= { sources: Layers, ai: Sparkles, api: KeyRound, - sharing: Share2, export: Download, advanced: Settings2, about: Info, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 2b83ff968..76f010ef5 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -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", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 954475e1f..22043e34a 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -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", diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx deleted file mode 100644 index 55096772d..000000000 --- a/src/components/settings/sharing-section.tsx +++ /dev/null @@ -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/`, 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 ( -
-
-

- {t("settings.sections.sharing.title")} -

-

- {t("settings.sections.sharing.description")} -

-
- - -
- ); -} - -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([ - "Patient", - "Observation", - ]); - const [newToken, setNewToken] = useState(null); - const [copied, setCopied] = useState(false); - const [formError, setFormError] = useState(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 ( -
- - -
{ - e.preventDefault(); - createMutation.mutate(); - }} - > -
- - setLabel(e.target.value)} - placeholder={t("settings.sharing.labelPlaceholder")} - maxLength={120} - /> -
- -
-
- - - setRangeDays(Math.max(1, Number(e.target.value) || 1)) - } - /> -

- {t("settings.sharing.rangeHint")} -

-
-
- - - setExpiryDays(Math.max(1, Number(e.target.value) || 1)) - } - aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS} - /> -

- {t("settings.sharing.expiryHint", { max: MAX_DAYS })} -

-
-
- -
-
- -

- {t("settings.sharing.fhirApiHint")} -

-
- -
- - {allowFhirApi && ( -
- - {t("settings.sharing.resourceTypes")} - -
- {RESOURCE_TYPES.map((type) => ( - - ))} -
-
- )} - - {formError && ( -

- {formError} -

- )} - - -
- - {newToken && ( -
-

- {t("settings.sharing.tokenCreated")} -

-

- {t("settings.sharing.tokenOnce")} -

-
- - {`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`} - - -
- {copied && ( -

- {t("settings.sharing.copied")} -

- )} -
- )} - -
-

- {t("settings.sharing.activeTitle")} -

- {activeLinks.length === 0 ? ( -

- {t("settings.sharing.noActive")} -

- ) : ( -
    - {activeLinks.map((link) => ( -
  • -
    -

    - {link.label} -

    -
    - - {t("settings.sharing.statusActive")} - - {link.allowFhirApi && ( - - FHIR - - )} -
    -
    -

    - - {t("settings.sharing.created")}: - {" "} - {formatDate(link.createdAt)} -

    -

    - - {t("settings.sharing.expires")}: - {" "} - {formatDateTime(link.expiresAt)} -

    -

    - - {t("settings.sharing.accessCount")}: - {" "} - {link.accessCount} - {link.lastAccessAt - ? ` · ${formatDateTime(link.lastAccessAt)}` - : ""} -

    - - - - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - -
  • - ))} -
- )} -
- - {inactiveLinks.length > 0 && ( -
- - {showRevoked && ( -
    - {inactiveLinks.map((link) => ( -
  • -
    -

    - {link.label} -

    - - {link.revokedAt - ? t("settings.sharing.statusRevoked") - : t("settings.sharing.statusExpired")} - -
    -

    - - {t("settings.sharing.accessCount")}: - {" "} - {link.accessCount} -

    -
  • - ))} -
- )} -
- )} -
- ); -} diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index 3c8b23580..9f7fc3fdf 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -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 ``. +// Doing it as a background (rather than an overlay element) keeps ` {children} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index d81dbcd6b..6f88f7b06 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -56,7 +56,14 @@ function SelectTrigger({ > {children} - + {/* `-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. */} + ); diff --git a/src/lib/doctor-report-pdf.ts b/src/lib/doctor-report-pdf.ts deleted file mode 100644 index 98ecf0a7f..000000000 --- a/src/lib/doctor-report-pdf.ts +++ /dev/null @@ -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; - } | null; -} - -type T = (key: string, params?: Record) => string; - -export interface DoctorReportOptions { - t: T; - locale: Locale; -} - -const TYPE_LABEL_KEYS: Record = { - 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 = { - 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 = { - 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 = { - 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 = [ - "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; -}