feat(export): one grouped scope picker, on both surfaces that choose a scope

The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent 5e490f31ec
commit 582db8a538
21 files changed
+1409 -1015

No files matched your search

+69 -50
View File
@@ -53,11 +53,15 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Prüfe die Messdaten und versuche es erneut." },
"create": {
"invalid": "Prüfe die Messdaten und versuche es erneut."
},
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden."
},
"mood": {
"list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." },
"list": {
"invalid": "Die Filter der Stimmungsliste sind ungültig."
},
"create": {
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
},
@@ -71,8 +75,12 @@
}
},
"labs": {
"create": { "invalid": "Prüfe den Laborwert und versuche es erneut." },
"result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." },
"create": {
"invalid": "Prüfe den Laborwert und versuche es erneut."
},
"result": {
"notFound": "Dieser Laborwert ist nicht mehr verfügbar."
},
"biomarker": {
"notFound": "Dieser Biomarker ist nicht mehr verfügbar.",
"duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.",
@@ -102,7 +110,9 @@
"complete": {
"invalid": "Prüfe die Profildaten und versuche es erneut."
},
"user": { "notFound": "Dein Konto wurde nicht gefunden." },
"user": {
"notFound": "Dein Konto wurde nicht gefunden."
},
"step": {
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.",
"invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.",
@@ -160,16 +170,6 @@
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
"typeWeight": "Körpergewicht",
"typeBpSys": "Systolischer Blutdruck",
"typeBpDia": "Diastolischer Blutdruck",
"typePulse": "Ruhepuls",
"typeBodyFat": "Körperfettanteil",
"typeSleep": "Schlafdauer",
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -205,8 +205,6 @@
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
"summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).",
"chartsTitle": "Trend-Diagramme",
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
"wellnessTitle": "Wellness-Zusammenfassung",
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
@@ -4447,10 +4445,6 @@
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
},
"cycle": {
"title": "Zyklus",
"description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
}
},
"import": {
@@ -5313,35 +5307,18 @@
"practiceName": "Praxis (optional)",
"practiceNamePlaceholder": "z. B. Dr. med. Mustermann",
"includedData": "Enthaltene Daten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Fitness",
"groupActivity": "Aktivität & Schlaf",
"weight": "Gewicht",
"bp": "Blutdruck",
"pulse": "Puls",
"oxygenSaturation": "Sauerstoffsättigung",
"bodyFat": "Körperfett",
"bodyComposition": "Körperzusammensetzung",
"restingHeartRate": "Ruhepuls",
"hrv": "Herzfrequenzvariabilität",
"vo2max": "VO₂max",
"steps": "Schritte",
"distance": "Distanz",
"sleep": "Schlaf",
"glucose": "Blutzucker",
"medications": "Medikamente & Therapietreue",
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
"mentalHealthScreeners": "Psychische Screenings (PHQ-9, GAD-7, WHO-5, SCI — standardmäßig aus)",
"bmi": "BMI",
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code}).",
"labs": "Laborwerte",
"allergies": "Allergien & Unverträglichkeiten",
"familyHistory": "Familienanamnese",
"documentsLink": "Dokumente zum Zeitraum"
"documentsLink": "Dokumente zum Zeitraum",
"range30": "Letzte 30 Tage",
"rangeCustom": "Eigener Zeitraum",
"rangeCustomStart": "Von",
"rangeCustomEnd": "Bis",
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.",
"errorEmptySelection": "Wählen Sie mindestens einen Eintrag aus."
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
@@ -5392,7 +5369,11 @@
"pickerEmpty": "Keine Dokumente gefunden.",
"pickerDone": "Fertig",
"pickerSelectedCount": "{count} von {max} ausgewählt",
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht.",
"scopeTitle": "Was dieser Link enthält",
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt."
},
"genderOther": "Divers",
"cycleTracking": {
@@ -6928,7 +6909,6 @@
"period": "Berichtszeitraum: {start} – {end}",
"expires": "Dieser Link läuft am {date} ab.",
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
"vitals": "Vitalwerte",
"labs": "Laborwerte",
"medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
@@ -6968,7 +6948,10 @@
"download": "Herunterladen",
"attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).",
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
}
},
"downloadPdf": "Als PDF herunterladen",
"downloadFhir": "Als FHIR herunterladen",
"bmiSection": "Body-Mass-Index"
},
"cycle": {
"symptomCategory": {
@@ -8875,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Stammdaten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Kreislauf",
"groupBody": "Körpermaße",
"groupSleepRecovery": "Schlaf & Erholung",
"groupActivity": "Aktivität",
"groupMobility": "Mobilität & Gang",
"groupEnvironment": "Umgebung",
"groupGlucose": "Blutzucker",
"groupLabs": "Laborwerte",
"groupMedications": "Medikamente",
"groupHistory": "Vorerkrankungen & Allergien",
"groupSensitive": "Sensible Daten",
"leafPatientIdentity": "Name, Geburtsdatum, Geschlecht, Größe",
"leafInsurance": "Krankenkasse und Versichertennummer",
"leafGlucosePanel": "Blutzucker-Panel (Zeit im Zielbereich, GMI, Variabilität)",
"leafLabResults": "Laborwerte",
"leafMedicationList": "Medikamentenliste",
"leafMedicationAdministrations": "Jede erfasste Einnahme",
"leafMedicationCompliance": "Einnahmetreue",
"leafGlp1Therapy": "GLP-1-Therapie (Dosisverlauf, Nebenwirkungen)",
"leafAllergies": "Allergien und Unverträglichkeiten",
"leafIllnessEpisodes": "Krankheitsepisoden",
"leafFamilyHistory": "Familienanamnese",
"leafMood": "Stimmung",
"leafCycle": "Menstruationszyklus",
"templateStandard": "Standard-Arztbericht",
"templateApplied": "{template} ist vorausgewählt. Prüfen Sie ihn vor dem Erstellen.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Einträge in {group} anzeigen",
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
"scopeNone": "Nichts ausgewählt — das Dokument wäre leer.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}"
}
}
+84 -55
View File
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Check the measurement details and try again." },
"create": {
"invalid": "Check the measurement details and try again."
},
"duplicate_timestamp": "A measurement already exists for this time."
},
"mood": {
"list": { "invalid": "The mood list filters are invalid." },
"create": { "invalid": "Check the mood entry and try again." },
"update": { "invalid": "Check the mood entry and try again." },
"list": {
"invalid": "The mood list filters are invalid."
},
"create": {
"invalid": "Check the mood entry and try again."
},
"update": {
"invalid": "Check the mood entry and try again."
},
"duplicate_timestamp": "A mood entry already exists for this time.",
"not_found": "This mood entry is no longer available.",
"ratedFactor": {
@@ -67,12 +75,18 @@
}
},
"labs": {
"create": { "invalid": "Check the lab result and try again." },
"result": { "notFound": "This lab result is no longer available." },
"create": {
"invalid": "Check the lab result and try again."
},
"result": {
"notFound": "This lab result is no longer available."
},
"biomarker": {
"notFound": "This biomarker is no longer available.",
"duplicate": "A biomarker with this name already exists.",
"create": { "invalid": "Check the biomarker details and try again." },
"create": {
"invalid": "Check the biomarker details and try again."
},
"update": {
"invalid": "Check the biomarker details and try again.",
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
@@ -90,9 +104,15 @@
"disclaimer": {
"invalid": "Please confirm the medical disclaimer again."
},
"tour": { "invalid": "The tour progress could not be saved." },
"complete": { "invalid": "Check the profile details and try again." },
"user": { "notFound": "Your account could not be found." },
"tour": {
"invalid": "The tour progress could not be saved."
},
"complete": {
"invalid": "Check the profile details and try again."
},
"user": {
"notFound": "Your account could not be found."
},
"step": {
"rateLimited": "Too many attempts. Please wait and try again.",
"invalid": "This onboarding step could not be saved.",
@@ -150,16 +170,6 @@
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
"typeWeight": "Body weight",
"typeBpSys": "Systolic blood pressure",
"typeBpDia": "Diastolic blood pressure",
"typePulse": "Resting pulse",
"typeBodyFat": "Body fat",
"typeSleep": "Sleep duration",
"typeSteps": "Activity (steps)",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
@@ -195,8 +205,6 @@
"summaryTrend": "{label}: latest {latest} {arrow}",
"summaryAdherence": "Medication adherence: {rate}% (weighted mean).",
"chartsTitle": "Trend charts",
"aiSummaryTitle": "AI summary — not clinically validated",
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
"wellnessTitle": "Wellness summary",
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
@@ -4437,10 +4445,6 @@
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
"stepUpNeeded": "Confirm your second factor, then try again."
},
"cycle": {
"title": "Cycle",
"description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
}
},
"import": {
@@ -5303,35 +5307,18 @@
"practiceName": "Practice (optional)",
"practiceNamePlaceholder": "e.g. Dr. Smith",
"includedData": "Included data",
"groupVitals": "Vital signs",
"groupCardio": "Heart & fitness",
"groupActivity": "Activity & sleep",
"weight": "Weight",
"bp": "Blood pressure",
"pulse": "Pulse",
"oxygenSaturation": "Oxygen saturation",
"bodyFat": "Body fat",
"bodyComposition": "Body composition",
"restingHeartRate": "Resting heart rate",
"hrv": "Heart rate variability",
"vo2max": "VO₂max",
"steps": "Steps",
"distance": "Distance",
"sleep": "Sleep",
"glucose": "Blood glucose",
"medications": "Medications & adherence",
"mood": "Mood (off by default — sensitive data)",
"mentalHealthScreeners": "Mental-health screening (PHQ-9, GAD-7, WHO-5, SCI — off by default)",
"bmi": "BMI",
"includeCharts": "Embed trend charts (PDF only)",
"includeAiSummary": "Append AI summary (not clinically validated)",
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code}).",
"labs": "Lab results",
"allergies": "Allergies & intolerances",
"familyHistory": "Family history",
"documentsLink": "Documents for this period"
"documentsLink": "Documents for this period",
"range30": "Last 30 days",
"rangeCustom": "Custom period",
"rangeCustomStart": "From",
"rangeCustomEnd": "To",
"errorGeneric": "The export could not be generated. Please try again.",
"errorRateLimit": "You have reached the export limit for this hour.",
"errorModuleDisabled": "The health record export is switched off for this account.",
"errorEmptySelection": "Pick at least one entry to include."
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
@@ -5382,7 +5369,11 @@
"pickerEmpty": "No documents found.",
"pickerDone": "Done",
"pickerSelectedCount": "{count} of {max} selected",
"pickerMaxReached": "You've reached the maximum of {max} documents."
"pickerMaxReached": "You've reached the maximum of {max} documents.",
"scopeTitle": "What this link contains",
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
"statusNeedsReselect": "Pick contents again",
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain."
},
"genderOther": "Other",
"cycleTracking": {
@@ -6918,7 +6909,6 @@
"period": "Reporting period: {start} – {end}",
"expires": "This link expires on {date}.",
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
"vitals": "Vital signs",
"labs": "Lab values",
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
@@ -6958,7 +6948,10 @@
"download": "Download",
"attachmentHint": "This file downloads to your device (no in-page preview).",
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
}
},
"downloadPdf": "Download as PDF",
"downloadFhir": "Download as FHIR",
"bmiSection": "Body mass index"
},
"cycle": {
"symptomCategory": {
@@ -8865,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Personal details",
"groupVitals": "Vital signs",
"groupCardio": "Heart & circulation",
"groupBody": "Body measurements",
"groupSleepRecovery": "Sleep & recovery",
"groupActivity": "Activity",
"groupMobility": "Mobility & gait",
"groupEnvironment": "Environment",
"groupGlucose": "Blood glucose",
"groupLabs": "Lab results",
"groupMedications": "Medications",
"groupHistory": "Conditions & allergies",
"groupSensitive": "Sensitive data",
"leafPatientIdentity": "Name, date of birth, gender, height",
"leafInsurance": "Insurer and insurance number",
"leafGlucosePanel": "Glucose panel (time in range, GMI, variability)",
"leafLabResults": "Lab results",
"leafMedicationList": "Medication list",
"leafMedicationAdministrations": "Every logged dose",
"leafMedicationCompliance": "Adherence rate",
"leafGlp1Therapy": "GLP-1 therapy (dose history, side effects)",
"leafAllergies": "Allergies and intolerances",
"leafIllnessEpisodes": "Illness episodes",
"leafFamilyHistory": "Family history",
"leafMood": "Mood",
"leafCycle": "Menstrual cycle",
"templateStandard": "Standard doctor's report",
"templateApplied": "{template} is preselected. Check it before you generate.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Show the entries in {group}",
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
"scopeNone": "Nothing selected — the document would be empty.",
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}"
}
}
+66 -49
View File
@@ -59,7 +59,9 @@
"duplicate_timestamp": "Ya existe una medición para esta hora."
},
"mood": {
"list": { "invalid": "Los filtros de la lista de ánimo no son válidos." },
"list": {
"invalid": "Los filtros de la lista de ánimo no son válidos."
},
"create": {
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
},
@@ -99,12 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Confirma de nuevo el aviso médico." },
"tour": { "invalid": "No se pudo guardar el progreso del recorrido." },
"disclaimer": {
"invalid": "Confirma de nuevo el aviso médico."
},
"tour": {
"invalid": "No se pudo guardar el progreso del recorrido."
},
"complete": {
"invalid": "Comprueba los datos del perfil e inténtalo de nuevo."
},
"user": { "notFound": "No se pudo encontrar tu cuenta." },
"user": {
"notFound": "No se pudo encontrar tu cuenta."
},
"step": {
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
"invalid": "No se pudo guardar este paso de la introducción.",
@@ -162,16 +170,6 @@
"footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
"footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
"typeWeight": "Peso corporal",
"typeBpSys": "Tensión arterial sistólica",
"typeBpDia": "Tensión arterial diastólica",
"typePulse": "Pulso en reposo",
"typeBodyFat": "Grasa corporal",
"typeSleep": "Duración del sueño",
"typeSteps": "Actividad (pasos)",
"typeTotalBodyWater": "Agua corporal total",
"typeBoneMass": "Masa ósea",
"typeOxygenSaturation": "Saturación de oxígeno",
"unitSteps": "Pasos",
"bmiUnderweight": "Peso insuficiente",
"bmiNormal": "Peso normal",
@@ -207,8 +205,6 @@
"summaryTrend": "{label}: último {latest} {arrow}",
"summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).",
"chartsTitle": "Gráficos de tendencia",
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
"wellnessTitle": "Resumen de bienestar",
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
@@ -4449,10 +4445,6 @@
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
},
"cycle": {
"title": "Ciclo",
"description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
}
},
"import": {
@@ -5315,35 +5307,18 @@
"practiceName": "Consulta (opcional)",
"practiceNamePlaceholder": "p. ej. Dr. García",
"includedData": "Datos incluidos",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y forma física",
"groupActivity": "Actividad y sueño",
"weight": "Peso",
"bp": "Presión arterial",
"pulse": "Pulso",
"oxygenSaturation": "Saturación de oxígeno",
"bodyFat": "Grasa corporal",
"bodyComposition": "Composición corporal",
"restingHeartRate": "Pulso en reposo",
"hrv": "Variabilidad de la frecuencia cardíaca",
"vo2max": "VO₂máx",
"steps": "Pasos",
"distance": "Distancia",
"sleep": "Sueño",
"glucose": "Glucosa en sangre",
"medications": "Medicamentos y adherencia",
"mood": "Estado de ánimo (desactivado por defecto — datos sensibles)",
"mentalHealthScreeners": "Cribado de salud mental (PHQ-9, GAD-7, WHO-5, SCI — desactivado por defecto)",
"bmi": "IMC",
"includeCharts": "Incrustar gráficos de tendencia (solo PDF)",
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code}).",
"labs": "Resultados de análisis",
"allergies": "Alergias e intolerancias",
"familyHistory": "Antecedentes familiares",
"documentsLink": "Documentos del período"
"documentsLink": "Documentos del período",
"range30": "Últimos 30 días",
"rangeCustom": "Periodo personalizado",
"rangeCustomStart": "Desde",
"rangeCustomEnd": "Hasta",
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.",
"errorEmptySelection": "Seleccione al menos una entrada."
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
@@ -5394,7 +5369,11 @@
"pickerEmpty": "No se encontraron documentos.",
"pickerDone": "Listo",
"pickerSelectedCount": "{count} de {max} seleccionados",
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos.",
"scopeTitle": "Qué contiene este enlace",
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido."
},
"genderOther": "Otro",
"cycleTracking": {
@@ -6930,7 +6909,6 @@
"period": "Período del informe: {start} – {end}",
"expires": "Este enlace caduca el {date}.",
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
"vitals": "Signos vitales",
"labs": "Valores de laboratorio",
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
@@ -6970,7 +6948,10 @@
"download": "Descargar",
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
}
},
"downloadPdf": "Descargar como PDF",
"downloadFhir": "Descargar como FHIR",
"bmiSection": "Índice de masa corporal"
},
"cycle": {
"symptomCategory": {
@@ -8877,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Datos personales",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y circulación",
"groupBody": "Medidas corporales",
"groupSleepRecovery": "Sueño y recuperación",
"groupActivity": "Actividad",
"groupMobility": "Movilidad y marcha",
"groupEnvironment": "Entorno",
"groupGlucose": "Glucosa en sangre",
"groupLabs": "Resultados de laboratorio",
"groupMedications": "Medicamentos",
"groupHistory": "Antecedentes y alergias",
"groupSensitive": "Datos sensibles",
"leafPatientIdentity": "Nombre, fecha de nacimiento, sexo, altura",
"leafInsurance": "Aseguradora y número de seguro",
"leafGlucosePanel": "Panel de glucosa (tiempo en rango, GMI, variabilidad)",
"leafLabResults": "Resultados de laboratorio",
"leafMedicationList": "Lista de medicamentos",
"leafMedicationAdministrations": "Cada dosis registrada",
"leafMedicationCompliance": "Adherencia",
"leafGlp1Therapy": "Terapia GLP-1 (historial de dosis, efectos secundarios)",
"leafAllergies": "Alergias e intolerancias",
"leafIllnessEpisodes": "Episodios de enfermedad",
"leafFamilyHistory": "Antecedentes familiares",
"leafMood": "Estado de ánimo",
"leafCycle": "Ciclo menstrual",
"templateStandard": "Informe médico estándar",
"templateApplied": "{template} está preseleccionado. Revíselo antes de generar.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostrar las entradas de {group}",
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
"scopeNone": "Nada seleccionado: el documento estaría vacío.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}"
}
}
+63 -48
View File
@@ -62,8 +62,12 @@
"list": {
"invalid": "Les filtres de la liste d’humeur ne sont pas valides."
},
"create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"create": {
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
},
"update": {
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
},
"duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.",
"not_found": "Cette entrée d’humeur n’est plus disponible.",
"ratedFactor": {
@@ -106,7 +110,9 @@
"complete": {
"invalid": "Vérifiez les informations du profil et réessayez."
},
"user": { "notFound": "Votre compte est introuvable." },
"user": {
"notFound": "Votre compte est introuvable."
},
"step": {
"rateLimited": "Trop de tentatives. Patientez puis réessayez.",
"invalid": "Cette étape d’accueil n’a pas pu être enregistrée.",
@@ -164,16 +170,6 @@
"footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
"footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
"typeWeight": "Poids corporel",
"typeBpSys": "Tension artérielle systolique",
"typeBpDia": "Tension artérielle diastolique",
"typePulse": "Pouls au repos",
"typeBodyFat": "Masse grasse",
"typeSleep": "Durée du sommeil",
"typeSteps": "Activité (pas)",
"typeTotalBodyWater": "Eau corporelle totale",
"typeBoneMass": "Masse osseuse",
"typeOxygenSaturation": "Saturation en oxygène",
"unitSteps": "Pas",
"bmiUnderweight": "Insuffisance pondérale",
"bmiNormal": "Poids normal",
@@ -209,8 +205,6 @@
"summaryTrend": "{label} : dernier {latest} {arrow}",
"summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).",
"chartsTitle": "Graphiques de tendance",
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
"wellnessTitle": "Résumé de bien-être",
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
@@ -4451,10 +4445,6 @@
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
},
"cycle": {
"title": "Cycle",
"description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
}
},
"import": {
@@ -5317,35 +5307,18 @@
"practiceName": "Cabinet (facultatif)",
"practiceNamePlaceholder": "p. ex. Dr Dupont",
"includedData": "Données incluses",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur & forme",
"groupActivity": "Activité & sommeil",
"weight": "Poids",
"bp": "Tension artérielle",
"pulse": "Pouls",
"oxygenSaturation": "Saturation en oxygène",
"bodyFat": "Masse grasse",
"bodyComposition": "Composition corporelle",
"restingHeartRate": "Pouls au repos",
"hrv": "Variabilité de la fréquence cardiaque",
"vo2max": "VO₂max",
"steps": "Pas",
"distance": "Distance",
"sleep": "Sommeil",
"glucose": "Glycémie",
"medications": "Médicaments & observance",
"mood": "Humeur (désactivé par défaut — données sensibles)",
"mentalHealthScreeners": "Dépistage de santé mentale (PHQ-9, GAD-7, WHO-5, SCI — désactivé par défaut)",
"bmi": "IMC",
"includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)",
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code}).",
"labs": "Résultats d'analyses",
"allergies": "Allergies et intolérances",
"familyHistory": "Antécédents familiaux",
"documentsLink": "Documents de la période"
"documentsLink": "Documents de la période",
"range30": "30 derniers jours",
"rangeCustom": "Période personnalisée",
"rangeCustomStart": "Du",
"rangeCustomEnd": "Au",
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.",
"errorEmptySelection": "Sélectionnez au moins un élément."
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
@@ -5396,7 +5369,11 @@
"pickerEmpty": "Aucun document trouvé.",
"pickerDone": "Terminé",
"pickerSelectedCount": "{count} sur {max} sélectionnés",
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents.",
"scopeTitle": "Ce que contient ce lien",
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
"statusNeedsReselect": "Choisir à nouveau le contenu",
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu."
},
"genderOther": "Autre",
"cycleTracking": {
@@ -6932,7 +6909,6 @@
"period": "Période couverte : {start} – {end}",
"expires": "Ce lien expire le {date}.",
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
"vitals": "Signes vitaux",
"labs": "Valeurs de laboratoire",
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
@@ -6972,7 +6948,10 @@
"download": "Télécharger",
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
}
},
"downloadPdf": "Télécharger en PDF",
"downloadFhir": "Télécharger en FHIR",
"bmiSection": "Indice de masse corporelle"
},
"cycle": {
"symptomCategory": {
@@ -8879,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Données personnelles",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur et circulation",
"groupBody": "Mensurations",
"groupSleepRecovery": "Sommeil et récupération",
"groupActivity": "Activité",
"groupMobility": "Mobilité et marche",
"groupEnvironment": "Environnement",
"groupGlucose": "Glycémie",
"groupLabs": "Résultats de laboratoire",
"groupMedications": "Médicaments",
"groupHistory": "Antécédents et allergies",
"groupSensitive": "Données sensibles",
"leafPatientIdentity": "Nom, date de naissance, sexe, taille",
"leafInsurance": "Assurance et numéro d'assuré",
"leafGlucosePanel": "Bilan glycémique (temps dans la cible, GMI, variabilité)",
"leafLabResults": "Résultats de laboratoire",
"leafMedicationList": "Liste des médicaments",
"leafMedicationAdministrations": "Chaque prise enregistrée",
"leafMedicationCompliance": "Observance",
"leafGlp1Therapy": "Traitement GLP-1 (historique des doses, effets indésirables)",
"leafAllergies": "Allergies et intolérances",
"leafIllnessEpisodes": "Épisodes de maladie",
"leafFamilyHistory": "Antécédents familiaux",
"leafMood": "Humeur",
"leafCycle": "Cycle menstruel",
"templateStandard": "Compte rendu médical standard",
"templateApplied": "{template} est présélectionné. Vérifiez-le avant de générer.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Afficher les éléments de {group}",
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
"scopeNone": "Rien de sélectionné — le document serait vide.",
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}"
}
}
+78 -53
View File
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Controlla i dati della misurazione e riprova." },
"create": {
"invalid": "Controlla i dati della misurazione e riprova."
},
"duplicate_timestamp": "Esiste già una misurazione per questo orario."
},
"mood": {
"list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." },
"create": { "invalid": "Controlla la voce dell’umore e riprova." },
"update": { "invalid": "Controlla la voce dell’umore e riprova." },
"list": {
"invalid": "I filtri dell’elenco dell’umore non sono validi."
},
"create": {
"invalid": "Controlla la voce dell’umore e riprova."
},
"update": {
"invalid": "Controlla la voce dell’umore e riprova."
},
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
"not_found": "Questa voce dell’umore non è più disponibile.",
"ratedFactor": {
@@ -76,7 +84,9 @@
"biomarker": {
"notFound": "Questo biomarcatore non è più disponibile.",
"duplicate": "Esiste già un biomarcatore con questo nome.",
"create": { "invalid": "Controlla i dati del biomarcatore e riprova." },
"create": {
"invalid": "Controlla i dati del biomarcatore e riprova."
},
"update": {
"invalid": "Controlla i dati del biomarcatore e riprova.",
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
@@ -91,12 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." },
"disclaimer": {
"invalid": "Conferma nuovamente l’avviso medico."
},
"tour": {
"invalid": "Non è stato possibile salvare l’avanzamento del tour."
},
"complete": { "invalid": "Controlla i dati del profilo e riprova." },
"user": { "notFound": "Impossibile trovare il tuo account." },
"complete": {
"invalid": "Controlla i dati del profilo e riprova."
},
"user": {
"notFound": "Impossibile trovare il tuo account."
},
"step": {
"rateLimited": "Troppi tentativi. Attendi e riprova.",
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
@@ -154,16 +170,6 @@
"footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
"footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
"typeWeight": "Peso corporeo",
"typeBpSys": "Pressione arteriosa sistolica",
"typeBpDia": "Pressione arteriosa diastolica",
"typePulse": "Pulsazioni a riposo",
"typeBodyFat": "Massa grassa",
"typeSleep": "Durata del sonno",
"typeSteps": "Attività (passi)",
"typeTotalBodyWater": "Acqua corporea totale",
"typeBoneMass": "Massa ossea",
"typeOxygenSaturation": "Saturazione di ossigeno",
"unitSteps": "Passi",
"bmiUnderweight": "Sottopeso",
"bmiNormal": "Peso normale",
@@ -199,8 +205,6 @@
"summaryTrend": "{label}: ultimo {latest} {arrow}",
"summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).",
"chartsTitle": "Grafici di tendenza",
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
"wellnessTitle": "Riepilogo del benessere",
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
@@ -4441,10 +4445,6 @@
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
"stepUpNeeded": "Conferma il secondo fattore e riprova."
},
"cycle": {
"title": "Ciclo",
"description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
}
},
"import": {
@@ -5307,35 +5307,18 @@
"practiceName": "Studio (facoltativo)",
"practiceNamePlaceholder": "es. Dr. Rossi",
"includedData": "Dati inclusi",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore & fitness",
"groupActivity": "Attività & sonno",
"weight": "Peso",
"bp": "Pressione sanguigna",
"pulse": "Polso",
"oxygenSaturation": "Saturazione di ossigeno",
"bodyFat": "Massa grassa",
"bodyComposition": "Composizione corporea",
"restingHeartRate": "Frequenza a riposo",
"hrv": "Variabilità della frequenza cardiaca",
"vo2max": "VO₂max",
"steps": "Passi",
"distance": "Distanza",
"sleep": "Sonno",
"glucose": "Glicemia",
"medications": "Farmaci e aderenza",
"mood": "Umore (disattivato di default — dati sensibili)",
"mentalHealthScreeners": "Screening di salute mentale (PHQ-9, GAD-7, WHO-5, SCI — disattivato di default)",
"bmi": "BMI",
"includeCharts": "Incorpora grafici di tendenza (solo PDF)",
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code}).",
"labs": "Risultati degli esami",
"allergies": "Allergie e intolleranze",
"familyHistory": "Anamnesi familiare",
"documentsLink": "Documenti del periodo"
"documentsLink": "Documenti del periodo",
"range30": "Ultimi 30 giorni",
"rangeCustom": "Periodo personalizzato",
"rangeCustomStart": "Dal",
"rangeCustomEnd": "Al",
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.",
"errorEmptySelection": "Seleziona almeno una voce."
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
@@ -5386,7 +5369,11 @@
"pickerEmpty": "Nessun documento trovato.",
"pickerDone": "Fatto",
"pickerSelectedCount": "{count} di {max} selezionati",
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti.",
"scopeTitle": "Cosa contiene questo link",
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
"statusNeedsReselect": "Scegli di nuovo il contenuto",
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere."
},
"genderOther": "Altro",
"cycleTracking": {
@@ -6922,7 +6909,6 @@
"period": "Periodo del report: {start} – {end}",
"expires": "Questo link scade il {date}.",
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
"vitals": "Parametri vitali",
"labs": "Valori di laboratorio",
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
@@ -6962,7 +6948,10 @@
"download": "Scarica",
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
}
},
"downloadPdf": "Scarica come PDF",
"downloadFhir": "Scarica come FHIR",
"bmiSection": "Indice di massa corporea"
},
"cycle": {
"symptomCategory": {
@@ -8869,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Dati anagrafici",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore e circolazione",
"groupBody": "Misure corporee",
"groupSleepRecovery": "Sonno e recupero",
"groupActivity": "Attività",
"groupMobility": "Mobilità e andatura",
"groupEnvironment": "Ambiente",
"groupGlucose": "Glicemia",
"groupLabs": "Esami di laboratorio",
"groupMedications": "Farmaci",
"groupHistory": "Patologie e allergie",
"groupSensitive": "Dati sensibili",
"leafPatientIdentity": "Nome, data di nascita, sesso, altezza",
"leafInsurance": "Assicurazione e numero di iscrizione",
"leafGlucosePanel": "Pannello glicemico (tempo nel range, GMI, variabilità)",
"leafLabResults": "Esami di laboratorio",
"leafMedicationList": "Elenco dei farmaci",
"leafMedicationAdministrations": "Ogni dose registrata",
"leafMedicationCompliance": "Aderenza",
"leafGlp1Therapy": "Terapia GLP-1 (storico dosi, effetti collaterali)",
"leafAllergies": "Allergie e intolleranze",
"leafIllnessEpisodes": "Episodi di malattia",
"leafFamilyHistory": "Anamnesi familiare",
"leafMood": "Umore",
"leafCycle": "Ciclo mestruale",
"templateStandard": "Referto medico standard",
"templateApplied": "{template} è preselezionato. Controllalo prima di generare.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostra le voci di {group}",
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
"scopeNone": "Niente selezionato: il documento sarebbe vuoto.",
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}"
}
}
+87 -56
View File
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." },
"create": {
"invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
},
"duplicate_timestamp": "Pomiar dla tego czasu już istnieje."
},
"mood": {
"list": { "invalid": "Filtry listy nastroju są nieprawidłowe." },
"create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"list": {
"invalid": "Filtry listy nastroju są nieprawidłowe."
},
"create": {
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
},
"update": {
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
},
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
"not_found": "Ten wpis nastroju nie jest już dostępny.",
"ratedFactor": {
@@ -67,12 +75,18 @@
}
},
"labs": {
"create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." },
"result": { "notFound": "Ten wynik badania nie jest już dostępny." },
"create": {
"invalid": "Sprawdź wynik badania i spróbuj ponownie."
},
"result": {
"notFound": "Ten wynik badania nie jest już dostępny."
},
"biomarker": {
"notFound": "Ten biomarker nie jest już dostępny.",
"duplicate": "Biomarker o tej nazwie już istnieje.",
"create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." },
"create": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie."
},
"update": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
@@ -87,10 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." },
"tour": { "invalid": "Nie udało się zapisać postępu prezentacji." },
"complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." },
"user": { "notFound": "Nie znaleziono Twojego konta." },
"disclaimer": {
"invalid": "Potwierdź ponownie informację medyczną."
},
"tour": {
"invalid": "Nie udało się zapisać postępu prezentacji."
},
"complete": {
"invalid": "Sprawdź dane profilu i spróbuj ponownie."
},
"user": {
"notFound": "Nie znaleziono Twojego konta."
},
"step": {
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
@@ -148,16 +170,6 @@
"footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
"footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
"typeWeight": "Masa ciała",
"typeBpSys": "Ciśnienie skurczowe",
"typeBpDia": "Ciśnienie rozkurczowe",
"typePulse": "Tętno spoczynkowe",
"typeBodyFat": "Tkanka tłuszczowa",
"typeSleep": "Czas snu",
"typeSteps": "Aktywność (kroki)",
"typeTotalBodyWater": "Całkowita woda w organizmie",
"typeBoneMass": "Masa kostna",
"typeOxygenSaturation": "Saturacja tlenem",
"unitSteps": "Kroki",
"bmiUnderweight": "Niedowaga",
"bmiNormal": "Waga prawidłowa",
@@ -193,8 +205,6 @@
"summaryTrend": "{label}: ostatnio {latest} {arrow}",
"summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).",
"chartsTitle": "Wykresy trendów",
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
"wellnessTitle": "Podsumowanie dobrostanu",
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
@@ -4435,10 +4445,6 @@
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
},
"cycle": {
"title": "Cykl",
"description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
}
},
"import": {
@@ -5301,35 +5307,18 @@
"practiceName": "Gabinet (opcjonalnie)",
"practiceNamePlaceholder": "np. dr Kowalski",
"includedData": "Uwzględnione dane",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i kondycja",
"groupActivity": "Aktywność i sen",
"weight": "Waga",
"bp": "Ciśnienie krwi",
"pulse": "Tętno",
"oxygenSaturation": "Saturacja tlenem",
"bodyFat": "Tkanka tłuszczowa",
"bodyComposition": "Skład ciała",
"restingHeartRate": "Tętno spoczynkowe",
"hrv": "Zmienność rytmu serca",
"vo2max": "VO₂max",
"steps": "Kroki",
"distance": "Dystans",
"sleep": "Sen",
"glucose": "Glukoza we krwi",
"medications": "Leki i przestrzeganie zaleceń",
"mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)",
"mentalHealthScreeners": "Przesiewowe testy zdrowia psychicznego (PHQ-9, GAD-7, WHO-5, SCI — domyślnie wyłączone)",
"bmi": "BMI",
"includeCharts": "Osadź wykresy trendów (tylko PDF)",
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code}).",
"labs": "Wyniki badań",
"allergies": "Alergie i nietolerancje",
"familyHistory": "Wywiad rodzinny",
"documentsLink": "Dokumenty z tego okresu"
"documentsLink": "Dokumenty z tego okresu",
"range30": "Ostatnie 30 dni",
"rangeCustom": "Własny okres",
"rangeCustomStart": "Od",
"rangeCustomEnd": "Do",
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.",
"errorEmptySelection": "Wybierz co najmniej jedną pozycję."
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
@@ -5380,7 +5369,11 @@
"pickerEmpty": "Nie znaleziono dokumentów.",
"pickerDone": "Gotowe",
"pickerSelectedCount": "Wybrano {count} z {max}",
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów.",
"scopeTitle": "Co zawiera ten link",
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
"statusNeedsReselect": "Wybierz zawartość ponownie",
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość."
},
"genderOther": "Inna",
"cycleTracking": {
@@ -6916,7 +6909,6 @@
"period": "Okres raportu: {start} – {end}",
"expires": "Ten link wygasa dnia {date}.",
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
"vitals": "Parametry życiowe",
"labs": "Wyniki laboratoryjne",
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
@@ -6956,7 +6948,10 @@
"download": "Pobierz",
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
}
},
"downloadPdf": "Pobierz jako PDF",
"downloadFhir": "Pobierz jako FHIR",
"bmiSection": "Wskaźnik masy ciała"
},
"cycle": {
"symptomCategory": {
@@ -8863,5 +8858,41 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Dane osobowe",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i krążenie",
"groupBody": "Wymiary ciała",
"groupSleepRecovery": "Sen i regeneracja",
"groupActivity": "Aktywność",
"groupMobility": "Mobilność i chód",
"groupEnvironment": "Otoczenie",
"groupGlucose": "Poziom glukozy",
"groupLabs": "Wyniki badań",
"groupMedications": "Leki",
"groupHistory": "Choroby i alergie",
"groupSensitive": "Dane wrażliwe",
"leafPatientIdentity": "Imię, data urodzenia, płeć, wzrost",
"leafInsurance": "Ubezpieczyciel i numer ubezpieczenia",
"leafGlucosePanel": "Panel glukozy (czas w zakresie, GMI, zmienność)",
"leafLabResults": "Wyniki badań",
"leafMedicationList": "Lista leków",
"leafMedicationAdministrations": "Każda zarejestrowana dawka",
"leafMedicationCompliance": "Przestrzeganie zaleceń",
"leafGlp1Therapy": "Terapia GLP-1 (historia dawek, działania niepożądane)",
"leafAllergies": "Alergie i nietolerancje",
"leafIllnessEpisodes": "Epizody chorobowe",
"leafFamilyHistory": "Wywiad rodzinny",
"leafMood": "Nastrój",
"leafCycle": "Cykl menstruacyjny",
"templateStandard": "Standardowy raport lekarski",
"templateApplied": "{template} jest wstępnie wybrany. Sprawdź go przed wygenerowaniem.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Pokaż pozycje w {group}",
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
"scopeNone": "Nic nie wybrano — dokument byłby pusty.",
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}"
}
}
+5
View File
@@ -110,6 +110,11 @@ export const GET = apiHandler(async () => {
// formatters and the <DateField> primitive.
dateFormat: user.dateFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// The owner's saved report selection — the leaf inclusion list plus the
// format / range / charts choices the export panel restores. `null` when
// the account has never saved one, which is what makes the panel show the
// named template on the first run rather than a silent server default.
reportSelection: user.reportSelectionJson ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
+1 -117
View File
@@ -22,9 +22,7 @@
*/
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CalendarHeart,
Download,
FileJson,
FileSpreadsheet,
@@ -44,9 +42,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { cn } from "@/lib/utils";
import { ImportPanel } from "@/components/settings/import-panel";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw } from "@/lib/api/api-fetch";
type ExportFormat = "CSV" | "JSON" | "FHIR";
@@ -96,11 +93,6 @@ export function ExportSection() {
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
{/* R30 — cycle export, gated on cycle tracking being enabled.
The card mounts only for accounts with cycle data; it reuses
the health-record FHIR export with the reproductive section
opted in, so there is no separate backend path. */}
<CycleExportCard />
</div>
</section>
@@ -556,111 +548,3 @@ function FullBackupCard() {
}
// ─────────────────────────── Cycle export (gated) ───────────────────────────
/**
* R30 — cycle / reproductive-health export, GATED on cycle tracking
* being enabled for the account. A standalone cycle export was removed
* from the cycle settings in v1.15.4 (it lives in the full backup); this
* surfaces an explicit, opt-in cycle export here for accounts that track
* a cycle. It reuses the flagship `/api/export/health-record` route with
* the reproductive section opted in and every other section off — no
* separate backend path. The card does not render at all when cycle
* tracking is off, so a non-cycle account never sees it.
*/
function CycleExportCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The ungated prefs read tells us whether cycle tracking is enabled
// without tripping the cycle gate's 403.
const prefsQuery = useQuery({
queryKey: queryKeys.cyclePrefs(),
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
return apiGet<{ cycleTrackingEnabled: boolean }>(
"/api/auth/me/cycle-prefs",
{ credentials: "include" },
);
},
staleTime: 5 * 60 * 1000,
});
// Gate: render nothing until we know cycle is on. A failed/absent read
// keeps the card hidden (fail-closed) so it never appears spuriously.
if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
format: "fhir",
sections: {
vitals: {},
cardioFitness: {},
activity: {},
glucose: false,
medications: {},
mood: false,
bmi: false,
cycle: true,
},
}),
});
if (!res.ok) {
setError(t("settings.sections.export.downloadFailed"));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-cycle"
icon={CalendarHeart}
title={t("settings.sections.export.cards.cycle.title")}
description={t("settings.sections.export.cards.cycle.description")}
format="FHIR"
footer={
<Button
data-testid="export-action-cycle"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleGenerate}
disabled={busy}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
@@ -28,7 +28,7 @@ import { Share2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
import { SharingSection } from "@/components/settings/sharing-section";
export function GesundheitsakteSection() {
@@ -1,102 +0,0 @@
/**
* The data-selection controls of the health-record export panel: the state
* shape, its starting values, and the wire selection they build.
*
* Split out of the panel component so the selection contract is testable and
* extendable without the client component around it. The panel owns layout and
* interaction; this module owns "which controls exist and what each one sends",
* which is the part the export route, the aggregator and the control-gating
* guard all have to agree with — and the part the grouped-selection work will
* replace with a grouped registry, one file, without touching the rendering.
*/
/**
* Every data-selection control this panel renders, as one flat state shape.
*
* The shape is load-bearing beyond the component: the control-gating
* guard (`src/__tests__/doctor-report-control-gating-guard.test.ts`) walks these
* keys, folds each one through {@link buildSelectionSections} and
* `toDoctorReportPrefs`, and fails the build unless flipping it changes the
* resolved selection the aggregator consumes. Adding a control here without a
* gating path behind it is a build error, which is how eight controls that
* changed nothing stopped being possible.
*/
export interface SectionState {
weight: boolean;
bp: boolean;
pulse: boolean;
oxygenSaturation: boolean;
bodyFat: boolean;
bodyComposition: boolean;
restingHeartRate: boolean;
hrv: boolean;
vo2max: boolean;
steps: boolean;
distance: boolean;
sleep: boolean;
glucose: boolean;
medList: boolean;
compliance: boolean;
mood: boolean;
bmi: boolean;
labs: boolean;
allergies: boolean;
familyHistory: boolean;
mentalHealthScreeners: boolean;
}
export const DEFAULT_SECTIONS: SectionState = {
weight: true,
bp: true,
pulse: true,
oxygenSaturation: true,
bodyFat: true,
bodyComposition: false,
restingHeartRate: false,
hrv: false,
vo2max: false,
steps: false,
distance: false,
sleep: false,
glucose: true,
medList: true,
compliance: true,
mood: false, // privacy default
bmi: true,
labs: true,
allergies: true,
familyHistory: true,
// Depression / anxiety / wellbeing / insomnia screening totals — opt-in,
// same stance as mood.
mentalHealthScreeners: false,
};
export function buildSelectionSections(s: SectionState) {
return {
vitals: {
weight: s.weight,
bp: s.bp,
pulse: s.pulse,
oxygenSaturation: s.oxygenSaturation,
bodyFat: s.bodyFat,
bodyComposition: s.bodyComposition,
},
cardioFitness: {
restingHeartRate: s.restingHeartRate,
hrv: s.hrv,
vo2max: s.vo2max,
},
activity: {
steps: s.steps,
distance: s.distance,
sleep: s.sleep,
},
glucose: s.glucose,
medications: { list: s.medList, compliance: s.compliance },
mood: s.mood,
bmi: s.bmi,
labs: s.labs,
allergies: s.allergies,
familyHistory: s.familyHistory,
mentalHealthScreeners: s.mentalHealthScreeners,
};
}
@@ -1,477 +0,0 @@
"use client";
/**
* v1.7.0 — Settings → Export → Health Record export panel.
*
* One surface that drives `POST /api/export/health-record`: a format
* toggle (PDF / FHIR / package) that drives the visible options below it
* (per the "dropdown drives the form, no top/bottom split" preference),
* a date-range picker, grouped data-section toggles, and the generate
* action that streams the artefact as a download.
*
* Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
* name, charts) hide when the format is FHIR. The ePA-compat note shows
* for the FHIR + package formats.
*
* v1.18.1 (D9) — the panel renders as a normal settings card (the v1.12
* gradient/glow hero treatment was dropped: the gradient read too crass next
* to the rest of the settings surface). The grouped data-section toggles live
* behind a collapsible disclosure, collapsed by default, so the panel opens
* compact instead of as a long always-expanded checklist.
*/
import Link from "next/link";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
Download,
FileText,
FolderOpen,
Loader2,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
DEFAULT_SECTIONS,
buildSelectionSections,
type SectionState,
} from "@/components/settings/health-record-export-controls";
type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState<
string | null
>(null);
const [includeCharts, setIncludeCharts] = useState(true);
const [includeAiSummary, setIncludeAiSummary] = useState(false);
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The included-data checklist is a disclosure, collapsed by default —
// the panel opens compact instead of as a long always-expanded list.
const [includedDataOpen, setIncludedDataOpen] = useState(false);
const includedDataPanelId = useId();
// Seed the practice name from the last export once the auth payload lands.
// `useAuth` resolves asynchronously, so a `useState` initializer would read
// `undefined` and stick at empty. Store-the-id-alongside-the-state (the same
// pattern the account form uses) syncs during render instead of in an
// effect, and the per-user gate means a late re-resolve never clobbers a
// field the user is already typing into.
if (user && user.id !== practiceNameSeededUserId) {
setPracticeNameSeededUserId(user.id);
setPracticeName(user.lastReportPracticeName ?? "");
}
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
count: EXPORT_FORMATS.length,
selectedIndex: EXPORT_FORMATS.indexOf(format),
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
function toggle(key: keyof SectionState) {
setSections((prev) => ({ ...prev, [key]: !prev[key] }));
}
// The single "Medikamente" control gates the whole medication block
// (list + compliance) so unchecking it actually excludes all medication
// data from the export — matching the iOS five-section model. Until
// that was true of the compliance figures only until this change: the list
// names, doses and schedules, and the per-dose ledger behind it, went out
// regardless of the switch.
function toggleMedications() {
setSections((prev) => {
const next = !(prev.medList && prev.compliance);
return { ...prev, medList: next, compliance: next };
});
}
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
format,
// Carry the active in-app locale so the generated artefact matches
// the UI language instead of falling back to the browser's
// Accept-Language header on the server.
locale,
range: { days },
practiceName: practiceName.trim() || undefined,
includeCharts,
includeAiSummary,
sections: buildSelectionSections(sections),
}),
});
if (!res.ok) {
setError(`${res.status}`);
return;
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name, so the cached `/me`
// payload is stale. Refresh it, or a remount of this panel would seed
// from the previous value.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsCard
as="section"
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
>
<SettingsCardHeader
className="mb-4"
icon={FileText}
titleId="health-record-export-title"
title={t("settings.healthRecord.title")}
description={t("settings.healthRecord.description")}
/>
<div className="space-y-4">
{/* Format + range share a row on desktop so the panel opens
denser; on mobile they stack. */}
<div className="grid gap-4 sm:grid-cols-2">
<fieldset className="space-y-1.5">
<legend className="mb-1 text-sm font-medium">
{t("settings.healthRecord.format")}
</legend>
<div className="flex flex-wrap gap-2" role="radiogroup">
{EXPORT_FORMATS.map((f, index) => (
<Button
key={f}
type="button"
role="radio"
aria-checked={format === f}
variant={format === f ? "default" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setFormat(f)}
{...getFormatRadioProps(index)}
>
{t(`settings.healthRecord.format_${f}`)}
</Button>
))}
</div>
</fieldset>
{/* Date range */}
<div className="space-y-1.5">
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
<NativeSelect
id="hr-range"
value={String(days)}
onChange={(e) => setDays(Number(e.target.value))}
>
<option value="90">{t("settings.healthRecord.range90")}</option>
<option value="180">{t("settings.healthRecord.range180")}</option>
<option value="365">{t("settings.healthRecord.range365")}</option>
</NativeSelect>
{/* Entry point into the document vault for the report period
(navigation only — attaching documents to the PDF is a later
phase). Year granularity is what the vault's URL filters
offer; the current year covers the bulk of any range. Only
rendered when the documents module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
) : null}
</div>
</div>
{/* Practice name — PDF only */}
{isPdfLike && (
<div className="space-y-1.5">
<Label htmlFor="hr-practice">
{t("settings.healthRecord.practiceName")}
</Label>
<Input
id="hr-practice"
value={practiceName}
onChange={(e) => setPracticeName(e.target.value)}
maxLength={120}
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
/>
</div>
)}
{/* Section groups — collapsible, collapsed by default so the
panel opens compact. The disclosure follows the inline
aria-expanded/aria-controls pattern used elsewhere in the app
(e.g. the insights recommendation cards). */}
<fieldset className="space-y-3">
<legend className="sr-only">
{t("settings.healthRecord.includedData")}
</legend>
<button
type="button"
data-testid="health-record-included-data-toggle"
aria-expanded={includedDataOpen}
aria-controls={includedDataPanelId}
onClick={() => setIncludedDataOpen((v) => !v)}
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("settings.healthRecord.includedData")}</span>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
includedDataOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{includedDataOpen && (
<div
id={includedDataPanelId}
data-testid="health-record-included-data-panel"
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupVitals")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.weight")}
checked={sections.weight}
onToggle={() => toggle("weight")}
/>
<ToggleRow
label={t("settings.healthRecord.bp")}
checked={sections.bp}
onToggle={() => toggle("bp")}
/>
<ToggleRow
label={t("settings.healthRecord.pulse")}
checked={sections.pulse}
onToggle={() => toggle("pulse")}
/>
<ToggleRow
label={t("settings.healthRecord.oxygenSaturation")}
checked={sections.oxygenSaturation}
onToggle={() => toggle("oxygenSaturation")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyFat")}
checked={sections.bodyFat}
onToggle={() => toggle("bodyFat")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyComposition")}
checked={sections.bodyComposition}
onToggle={() => toggle("bodyComposition")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupCardio")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.restingHeartRate")}
checked={sections.restingHeartRate}
onToggle={() => toggle("restingHeartRate")}
/>
<ToggleRow
label={t("settings.healthRecord.hrv")}
checked={sections.hrv}
onToggle={() => toggle("hrv")}
/>
<ToggleRow
label={t("settings.healthRecord.vo2max")}
checked={sections.vo2max}
onToggle={() => toggle("vo2max")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupActivity")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.steps")}
checked={sections.steps}
onToggle={() => toggle("steps")}
/>
<ToggleRow
label={t("settings.healthRecord.distance")}
checked={sections.distance}
onToggle={() => toggle("distance")}
/>
<ToggleRow
label={t("settings.healthRecord.sleep")}
checked={sections.sleep}
onToggle={() => toggle("sleep")}
/>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.glucose")}
checked={sections.glucose}
onToggle={() => toggle("glucose")}
/>
<ToggleRow
label={t("settings.healthRecord.medications")}
checked={sections.medList && sections.compliance}
onToggle={toggleMedications}
/>
<ToggleRow
label={t("settings.healthRecord.bmi")}
checked={sections.bmi}
onToggle={() => toggle("bmi")}
/>
<ToggleRow
label={t("settings.healthRecord.mood")}
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
<ToggleRow
label={t("settings.healthRecord.mentalHealthScreeners")}
checked={sections.mentalHealthScreeners}
onToggle={() => toggle("mentalHealthScreeners")}
/>
<ToggleRow
label={t("settings.healthRecord.labs")}
checked={sections.labs}
onToggle={() => toggle("labs")}
/>
<ToggleRow
label={t("settings.healthRecord.allergies")}
checked={sections.allergies}
onToggle={() => toggle("allergies")}
/>
<ToggleRow
label={t("settings.healthRecord.familyHistory")}
checked={sections.familyHistory}
onToggle={() => toggle("familyHistory")}
/>
</div>
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeCharts")}
checked={includeCharts}
onToggle={() => setIncludeCharts((v) => !v)}
/>
)}
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeAiSummary")}
checked={includeAiSummary}
onToggle={() => setIncludeAiSummary((v) => !v)}
/>
)}
</div>
)}
</fieldset>
{/* The FHIR note and the generate action share the footer row so
the panel ends compact instead of stacking a note above a
right-aligned button. */}
<div className="flex flex-wrap items-center justify-between gap-3">
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : (
<span />
)}
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
className="ml-auto min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-4 w-4" />
)}
{t("settings.healthRecord.generate")}
</Button>
</div>
{error && (
<p role="alert" className="text-destructive text-sm">
{t("settings.healthRecord.error", { code: error })}
</p>
)}
</div>
</SettingsCard>
);
}
function ToggleRow({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<label className="flex items-center justify-between gap-3 text-sm">
<span>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -0,0 +1,105 @@
"use client";
/**
* One group row in the scope picker.
*
* The row is a `ListRow` carrying a group checkbox, the group name, a count
* chip, and a disclosure that opens the leaf grid. The group checkbox is one
* control for up to sixteen leaves, which is what makes the repeat visit two
* interactions; the chevron is the escape hatch and never sits on the fast
* path.
*
* `mixed` is a derived display state and never a click target: clicking a
* partly-selected group turns it fully on. Only leaves are ever stored.
*/
import { ChevronDown } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { ListRow } from "@/components/ui/list-row";
import { TagChip } from "@/components/ui/tag-chip";
import { cn } from "@/lib/utils";
import type {
ReportGroupId,
ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { GroupCheckState } from "@/lib/report-selection/panel-state";
import { LeafGrid } from "./leaf-grid";
export function GroupRow({
t,
id,
labelKey,
leaves,
selected,
checkState,
count,
open,
panelId,
onToggleGroup,
onToggleLeaf,
onToggleOpen,
}: {
t: (key: string, vars?: Record<string, string | number>) => string;
id: ReportGroupId;
labelKey: string;
leaves: readonly ReportLeafId[];
selected: ReadonlySet<ReportLeafId>;
checkState: GroupCheckState;
count: { on: number; total: number };
open: boolean;
panelId: string;
onToggleGroup: () => void;
onToggleLeaf: (leaf: ReportLeafId) => void;
onToggleOpen: () => void;
}) {
const label = t(labelKey);
return (
<ListRow className="space-y-2" data-testid={`report-group-row-${id}`}>
<div className="flex min-h-11 items-center gap-3 sm:min-h-9">
<Checkbox
checked={
checkState === "all"
? true
: checkState === "mixed"
? "indeterminate"
: false
}
onCheckedChange={onToggleGroup}
aria-label={label}
data-testid={`report-group-check-${id}`}
/>
<span className="text-foreground min-w-0 flex-1 text-sm">{label}</span>
<TagChip>
{t("reportSelection.groupCount", {
on: count.on,
total: count.total,
})}
</TagChip>
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
aria-label={t("reportSelection.groupDisclosure", { group: label })}
onClick={onToggleOpen}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ChevronDown
className={cn("size-4 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
</div>
{open ? (
<div id={panelId}>
<LeafGrid
t={t}
leaves={leaves}
selected={selected}
onToggle={onToggleLeaf}
/>
</div>
) : null}
</ListRow>
);
}
@@ -0,0 +1,57 @@
"use client";
/**
* The per-leaf checkbox grid inside an expanded group.
*
* A dense read-out, not a field grid: `gap-2` on the enforced scale, two
* columns from the `sm` breakpoint. Each row clears the tap floor and carries
* a stable test id so an end-to-end assertion binds to the leaf, not to text
* that a breakpoint may hide.
*/
import { Checkbox } from "@/components/ui/checkbox";
import { MEASUREMENT_TYPE_ICONS } from "@/components/measurements/measurement-list-meta";
import {
leafLabelKey,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
export function LeafGrid({
t,
leaves,
selected,
onToggle,
}: {
t: (key: string, vars?: Record<string, string | number>) => string;
leaves: readonly ReportLeafId[];
selected: ReadonlySet<ReportLeafId>;
onToggle: (leaf: ReportLeafId) => void;
}) {
return (
<div className="grid gap-2 sm:grid-cols-2">
{leaves.map((leaf) => {
const Icon = MEASUREMENT_TYPE_ICONS[leaf];
return (
<label
key={leaf}
data-testid={`report-leaf-${leaf}`}
className="flex min-h-11 items-center gap-3 text-sm sm:min-h-9"
>
<Checkbox
checked={selected.has(leaf)}
onCheckedChange={() => onToggle(leaf)}
/>
{Icon ? (
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
) : null}
<span className="text-foreground min-w-0 flex-1">
{t(leafLabelKey(leaf))}
</span>
</label>
);
})}
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
/**
* The one scope picker. Both surfaces that choose what a report contains — the
* export panel and the share-link create form — mount this exact component, so
* two selection UIs cannot drift apart.
*
* Twelve group rows plus a fenced tier below a separator. The fenced tier has
* NO group checkbox: there is no control anywhere in this component that can
* turn on more than one sensitive leaf at a time, and there is no select-all.
* The fence uses no colour to mark itself — a separator, a heading, and
* per-leaf controls. A red tint would be decoration; status colour is reserved
* for meaning.
*
* The component is controlled: it owns no selection state, only which rows are
* expanded and what each group looked like before it was switched off.
*/
import { useId, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import {
REPORT_GROUPS,
SENSITIVE_GROUP_ID,
type ReportGroupId,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import {
groupCheckState,
groupCount,
toggleGroup,
toggleLeaf,
} from "@/lib/report-selection/panel-state";
import { GroupRow } from "./group-row";
import { LeafGrid } from "./leaf-grid";
export function ReportScopePicker({
selected,
onChange,
hiddenLeaves,
}: {
selected: ReadonlySet<ReportLeafId>;
onChange: (next: Set<ReportLeafId>) => void;
/**
* Leaves this surface must not offer at all. The share-link form passes the
* insurance leaf: the server refuses it with a 422, and a control that
* cannot be honoured has no business rendering.
*/
hiddenLeaves?: readonly ReportLeafId[];
}) {
const { t } = useTranslations();
const idPrefix = useId();
const [openGroups, setOpenGroups] = useState<ReadonlySet<ReportGroupId>>(
new Set(),
);
// What each group looked like before it was switched off, so switching it
// back on inside one session restores the pattern instead of flooding it.
const [remembered, setRemembered] = useState<ReadonlySet<ReportLeafId>>(
new Set(),
);
const hidden = new Set(hiddenLeaves ?? []);
const groups = REPORT_GROUPS.map((group) => ({
...group,
leaves: group.leaves.filter((leaf) => !hidden.has(leaf)),
})).filter((group) => group.leaves.length > 0);
const plainGroups = groups.filter((g) => g.id !== SENSITIVE_GROUP_ID);
const sensitiveGroup = groups.find((g) => g.id === SENSITIVE_GROUP_ID);
const handleLeaf = (leaf: ReportLeafId) => {
setRemembered(new Set(selected));
onChange(toggleLeaf(selected, leaf));
};
const handleGroup = (group: ReportGroupId) => {
setRemembered(new Set(selected));
onChange(toggleGroup(selected, group, remembered));
};
return (
<div className="space-y-3" data-testid="report-scope-picker">
{plainGroups.map((group) => (
<GroupRow
key={group.id}
t={t}
id={group.id}
labelKey={group.labelKey}
leaves={group.leaves}
selected={selected}
checkState={groupCheckState(group.id, selected)}
count={groupCount(group.id, selected)}
open={openGroups.has(group.id)}
panelId={`${idPrefix}-${group.id}`}
onToggleGroup={() => handleGroup(group.id)}
onToggleLeaf={handleLeaf}
onToggleOpen={() =>
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(group.id)) next.delete(group.id);
else next.add(group.id);
return next;
})
}
/>
))}
{sensitiveGroup ? (
<div data-testid="report-sensitive-tier">
<Separator className="my-4" />
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
{t(sensitiveGroup.labelKey)}
</p>
<p className="text-muted-foreground mb-2 text-xs">
{t("reportSelection.sensitiveHint")}
</p>
<LeafGrid
t={t}
leaves={sensitiveGroup.leaves}
selected={selected}
onToggle={handleLeaf}
/>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
/**
* The one line that says what the artefact will contain.
*
* This is the consent check: reading it IS the review. Fenced inclusions are
* restated by name rather than folded into the count, because a number cannot
* be checked at a glance and "incl. Mood, PHQ-9" can.
*
* Meta tier — one sentence about the form beside it, not content.
*/
import { scopeSummary } from "@/lib/report-selection/panel-state";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
export function ScopeSummary({
t,
selected,
}: {
t: (key: string, vars?: Record<string, string | number>) => string;
selected: ReadonlySet<ReportLeafId>;
}) {
const summary = scopeSummary(selected);
if (summary.total === 0) {
return (
<p
className="text-muted-foreground text-xs"
data-testid="report-scope-summary"
>
{t("reportSelection.scopeNone")}
</p>
);
}
const base = t("reportSelection.scopeCount", {
count: summary.total,
groups: summary.groups,
});
const sensitive =
summary.sensitiveLabelKeys.length > 0
? t("reportSelection.scopeSensitive", {
names: summary.sensitiveLabelKeys.map((key) => t(key)).join(", "),
})
: null;
return (
<p
className="text-muted-foreground text-xs"
data-testid="report-scope-summary"
>
{sensitive ? `${base} · ${sensitive}` : base}
</p>
);
}
@@ -0,0 +1,384 @@
"use client";
/**
* Settings → Export → the health-record export panel.
*
* One surface driving `POST /api/export/health-record`: a format radio, the
* reporting window, the practice line, the scope picker, and the generate
* action that streams the artefact as a download.
*
* Three states, by design:
*
* 1. First run (no saved selection): the picker opens EXPANDED with the
* named standard template applied and the fenced tier visibly empty, so
* the user presses Generate having seen twelve named groups. That is
* consent to a named bundle, not a server-chosen default.
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
* selection is loaded, and the scope line under the button says what will
* be in the document. Two interactions after navigation, and the reading
* is the consent check.
* 3. Editing: a group row is one control for up to sixteen leaves; the leaf
* grid opens only when the scope actually changes, which is rare.
*
* The saved selection seeds per-user during render with a stored-id guard —
* `useAuth` resolves asynchronously, so a `useState` initialiser would read
* `undefined` and stick — and the per-user gate means a late re-resolve never
* clobbers edits the user is in the middle of.
*/
import Link from "next/link";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
Download,
FileText,
FolderOpen,
Loader2,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { parseSavedProfile } from "@/lib/report-selection/saved-profile";
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
import { ScopeSummary } from "./scope-summary";
type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
const PRESET_RANGES = [30, 90, 180, 365] as const;
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [customRange, setCustomRange] = useState(false);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [practiceName, setPracticeName] = useState("");
const [includeCharts, setIncludeCharts] = useState(true);
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
() => new Set(STANDARD_TEMPLATE_LEAVES),
);
const [seededUserId, setSeededUserId] = useState<string | null>(null);
// First run opens the picker so the template is seen, not merely applied.
const [pickerOpen, setPickerOpen] = useState(true);
const [templateApplied, setTemplateApplied] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerPanelId = useId();
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
setPracticeName(user.lastReportPracticeName ?? "");
const saved = parseSavedProfile(user.reportSelection);
if (saved) {
setSelected(new Set(saved.leaves as ReportLeafId[]));
setFormat(saved.format);
setDays(saved.rangeDays);
setIncludeCharts(saved.includeCharts);
setPickerOpen(false);
setTemplateApplied(false);
}
}
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
count: EXPORT_FORMATS.length,
selectedIndex: EXPORT_FORMATS.indexOf(format),
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
async function handleGenerate() {
if (selected.size === 0) {
setError(t("settings.healthRecord.errorEmptySelection"));
return;
}
setBusy(true);
setError(null);
try {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
format,
// Carry the active in-app locale so the artefact matches the UI
// language instead of falling back to Accept-Language on the server.
locale,
range,
practiceName: practiceName.trim() || undefined,
includeCharts,
selection: { v: 2, leaves: orderLeaves(selected) },
}),
});
if (!res.ok) {
setError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
return;
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setTemplateApplied(false);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsCard
as="section"
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
>
<SettingsCardHeader
className="mb-4"
icon={FileText}
titleId="health-record-export-title"
title={t("settings.healthRecord.title")}
description={t("settings.healthRecord.description")}
/>
<div className="space-y-4">
{/* Format + range share a row on desktop; on mobile they stack. */}
<div className="grid gap-4 sm:grid-cols-2">
<fieldset className="space-y-1.5">
<legend className="mb-1 text-sm font-medium">
{t("settings.healthRecord.format")}
</legend>
<div className="flex flex-wrap gap-2" role="radiogroup">
{EXPORT_FORMATS.map((f, index) => (
<Button
key={f}
type="button"
role="radio"
aria-checked={format === f}
variant={format === f ? "default" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setFormat(f)}
{...getFormatRadioProps(index)}
>
{t(`settings.healthRecord.format_${f}`)}
</Button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
<NativeSelect
id="hr-range"
value={customRange ? "custom" : String(days)}
onChange={(e) => {
if (e.target.value === "custom") {
setCustomRange(true);
return;
}
setCustomRange(false);
setDays(Number(e.target.value));
}}
>
{PRESET_RANGES.map((preset) => (
<option key={preset} value={String(preset)}>
{t(`settings.healthRecord.range${preset}`)}
</option>
))}
<option value="custom">
{t("settings.healthRecord.rangeCustom")}
</option>
</NativeSelect>
{/* Entry point into the document vault for the report period
(navigation only). Only rendered when the module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
) : null}
</div>
</div>
{customRange ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hr-range-start">
{t("settings.healthRecord.rangeCustomStart")}
</Label>
<DateField
id="hr-range-start"
value={startDate}
onChange={setStartDate}
max={endDate || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hr-range-end">
{t("settings.healthRecord.rangeCustomEnd")}
</Label>
<DateField
id="hr-range-end"
value={endDate}
onChange={setEndDate}
min={startDate || undefined}
/>
</div>
</div>
) : null}
{/* Practice name — PDF only */}
{isPdfLike && (
<div className="space-y-1.5">
<Label htmlFor="hr-practice">
{t("settings.healthRecord.practiceName")}
</Label>
<Input
id="hr-practice"
value={practiceName}
onChange={(e) => setPracticeName(e.target.value)}
maxLength={120}
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
/>
</div>
)}
<fieldset className="space-y-3">
<legend className="sr-only">
{t("settings.healthRecord.includedData")}
</legend>
<button
type="button"
data-testid="health-record-included-data-toggle"
aria-expanded={pickerOpen}
aria-controls={pickerPanelId}
onClick={() => setPickerOpen((v) => !v)}
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("settings.healthRecord.includedData")}</span>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
pickerOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{pickerOpen && (
<div
id={pickerPanelId}
data-testid="health-record-included-data-panel"
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
{templateApplied ? (
<p
className="text-muted-foreground text-xs"
data-testid="report-template-note"
>
{t("reportSelection.templateApplied", {
template: t("reportSelection.templateStandard"),
})}
</p>
) : null}
<ReportScopePicker selected={selected} onChange={setSelected} />
{isPdfLike ? (
<label className="flex min-h-11 items-center justify-between gap-3 text-sm sm:min-h-9">
<span className="text-foreground">
{t("settings.healthRecord.includeCharts")}
</span>
<Switch
checked={includeCharts}
onCheckedChange={() => setIncludeCharts((v) => !v)}
/>
</label>
) : null}
</div>
)}
</fieldset>
{/* The FHIR note and the generate action share the footer row. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<ScopeSummary t={t} selected={selected} />
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : null}
</div>
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-4 w-4" />
)}
{t("settings.healthRecord.generate")}
</Button>
</div>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</div>
</SettingsCard>
);
}
@@ -32,6 +32,11 @@ import {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker";
import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { orderLeaves } from "@/lib/report-selection/selection";
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
@@ -58,6 +63,13 @@ export interface ShareLinkSummary {
protected: boolean;
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
documentCount: number;
/**
* Whether this link's frozen scope predates the selection model. Such links
* were revoked on upgrade — their scope was "everything except mood and
* cycle", which nobody chose — and the sharing list says so, so the owner
* re-mints rather than wondering why a working link stopped.
*/
needsReselection: boolean;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
@@ -72,6 +84,14 @@ export interface ShareLinkSummary {
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
*/
export interface ShareLinkCreated extends ShareLinkSummary {
/**
* RETIRED, response-only. They configured a FHIR face that was never built;
* the request refuses them and the columns are gone. They stay here because
* the native client was given this shape and a missing non-optional key
* fails a whole Swift decode — see #70 in the native repository.
*/
allowFhirApi: boolean;
resourceTypes: string[];
token: string;
passphrase: string;
shareUrl: string;
@@ -87,17 +107,25 @@ export interface ShareLinkCreatePayload {
label: string;
rangeStart: string;
rangeEnd: null;
selection?: { v: 2; leaves: readonly string[] };
documentIds?: string[];
documentOnly?: boolean;
expiresAt: string;
}
/**
* The one leaf a share link may never carry — mirrored from the server, which
* refuses it with a 422. The picker hides it rather than rendering a control
* that cannot be honoured.
*/
const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"];
/**
* Build the create payload from the resolved form state. Extracted as a pure
* function so the two surfaces' scope contract is unit-testable without a DOM.
*
* The `documentOnly` branch is the privacy-load-bearing one: a document-launched
* share posts an EMPTY report scope (no `sections`) plus `documentOnly: true`,
* share posts NO report scope at all plus `documentOnly: true`,
* and ALWAYS carries the picked document ids —
* so the created link serves the document(s) and nothing else. The record share
* (Settings → Sharing) keeps its full scope and only attaches documents when
@@ -107,6 +135,7 @@ export function buildShareLinkCreatePayload(input: {
label: string;
rangeDays: number;
expiryDays: number;
leaves: readonly ReportLeafId[];
documentIds: string[];
documentOnly: boolean;
}): ShareLinkCreatePayload {
@@ -127,6 +156,7 @@ export function buildShareLinkCreatePayload(input: {
}
return {
...base,
selection: { v: 2, leaves: input.leaves },
// Omit the key entirely when nothing is attached (a documents-less record
// share stays the default). The server re-validates each id as the caller's
// own live document before minting the link.
@@ -163,6 +193,18 @@ export function ShareLinkCreateForm({
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
initialDocuments ?? [],
);
// The named standard template is the starting point for a record share, the
// same one the export panel opens with, minus the leaf a link may not carry.
const [selectedLeaves, setSelectedLeaves] = useState<
ReadonlySet<ReportLeafId>
>(
() =>
new Set(
STANDARD_TEMPLATE_LEAVES.filter(
(leaf) => !SHARE_HIDDEN_LEAVES.includes(leaf),
),
),
);
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
@@ -212,6 +254,7 @@ export function ShareLinkCreateForm({
label,
rangeDays,
expiryDays,
leaves: orderLeaves(selectedLeaves),
documentIds: selectedDocs.map((d) => d.id),
documentOnly,
}),
@@ -310,6 +353,26 @@ export function ShareLinkCreateForm({
</div>
</div>
{/* ── What the link contains ───────────────────────────────── */}
{!documentOnly ? (
<div className="space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("settings.sharing.scopeTitle")}
</Label>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.scopeInsuranceExcluded")}
</p>
</div>
<ReportScopePicker
selected={selectedLeaves}
onChange={setSelectedLeaves}
hiddenLeaves={SHARE_HIDDEN_LEAVES}
/>
<ScopeSummary t={t} selected={selectedLeaves} />
</div>
) : null}
{/* ── Attach documents ─────────────────────────────────────── */}
<div className="border-border space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
@@ -384,7 +447,11 @@ export function ShareLinkCreateForm({
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
+17 -3
View File
@@ -236,11 +236,25 @@ function ShareLinksCard() {
{link.label}
</p>
<Badge variant="secondary" className="text-xs">
{link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
It served a scope nobody chose, so it was closed; the
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
<p
className="text-muted-foreground text-xs"
data-testid={`share-needs-reselect-${link.id}`}
>
{t("settings.sharing.reselectReason")}
</p>
) : null}
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
+13 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { Check } from "lucide-react";
import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -16,6 +16,12 @@ import { cn } from "@/lib/utils";
* Mirrors the v1.4.27 shadcn `<Checkbox>` recipe: a Root that draws
* the border + checked-state background, an Indicator slot that
* shows the lucide `<Check>` when the box is checked.
*
* The Indicator also draws a `<Minus>` for the indeterminate state, which
* Radix's Root accepts natively via `checked="indeterminate"`. A group row
* whose leaves are partly selected is a real third state and needs to look
* like one — drawing it as unchecked would say "none of this is included",
* which on this surface is a false statement about what leaves the instance.
*/
function Checkbox({
className,
@@ -34,6 +40,7 @@ function Checkbox({
// blanket neighbouring controls in dense table rows.
"peer border-border/70 relative size-4 shrink-0 rounded border before:absolute before:inset-[-8px] before:content-['']",
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-background",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",
@@ -45,7 +52,11 @@ function Checkbox({
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
<Check className="size-3" aria-hidden="true" />
{props.checked === "indeterminate" ? (
<Minus className="size-3" aria-hidden="true" />
) : (
<Check className="size-3" aria-hidden="true" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
+7
View File
@@ -123,6 +123,13 @@ export interface AuthUser {
* user has never set one, or deliberately cleared the line.
*/
lastReportPracticeName: string | null;
/**
* The saved report selection blob (`{ v, leaves, format, rangeDays,
* includeCharts }`), or `null` when the account has never saved one. Parsed
* through `parseSavedProfile` at the one place that reads it, so a drifted
* shape resolves to "never saved" rather than to a partial scope.
*/
reportSelection: unknown;
/**
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
* + the per-user opt-in. The cycle nav entry + page hide when false; every
+124
View File
@@ -0,0 +1,124 @@
/**
* The pure state of the report scope picker.
*
* Kept out of the client component so the control shape is testable without a
* DOM, and so the structural guard can import a lib module rather than reach
* into a component. The panel owns layout and interaction; this owns "which
* controls exist, what each one means, and what the scope line says".
*
* Only leaves are ever stored. A group's state is DERIVED from its leaves, so
* a group and its leaves cannot disagree — there is no second source of truth
* to reconcile.
*/
import {
REPORT_GROUPS,
SENSITIVE_GROUP_ID,
leafLabelKey,
type ReportGroupId,
type ReportLeafId,
} from "./catalogue";
/** The panel's own state: the set of chosen leaf ids. */
export type PanelSelection = ReadonlySet<ReportLeafId>;
/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
export type GroupCheckState = "all" | "none" | "mixed";
export function groupCheckState(
group: ReportGroupId,
selected: PanelSelection,
): GroupCheckState {
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
if (leaves.length === 0) return "none";
const on = leaves.filter((leaf) => selected.has(leaf)).length;
if (on === 0) return "none";
if (on === leaves.length) return "all";
return "mixed";
}
/** How many of a group's leaves are chosen, and how many there are. */
export function groupCount(
group: ReportGroupId,
selected: PanelSelection,
): { on: number; total: number } {
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
return {
on: leaves.filter((leaf) => selected.has(leaf)).length,
total: leaves.length,
};
}
/** Toggle one leaf. */
export function toggleLeaf(
selected: PanelSelection,
leaf: ReportLeafId,
): Set<ReportLeafId> {
const next = new Set(selected);
if (next.has(leaf)) next.delete(leaf);
else next.add(leaf);
return next;
}
/**
* Toggle a whole group.
*
* `mixed` and `none` both go to all-on; `all` goes to all-off. Turning a group
* off and back on within one session restores the previous leaf pattern via
* `remembered`, so the escape hatch does not cost the user their earlier
* refinements. Across sessions only leaves persist and the group state is
* re-derived.
*
* The fenced tier has no group control at all — `REPORT_GROUPS` still lists
* it, but the panel renders no checkbox for it, and this function refuses it
* so no future caller can route around that.
*/
export function toggleGroup(
selected: PanelSelection,
group: ReportGroupId,
remembered?: PanelSelection,
): Set<ReportLeafId> {
if (group === SENSITIVE_GROUP_ID) return new Set(selected);
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
const next = new Set(selected);
if (groupCheckState(group, selected) === "all") {
for (const leaf of leaves) next.delete(leaf);
return next;
}
const restore = remembered
? leaves.filter((leaf) => remembered.has(leaf))
: [];
for (const leaf of restore.length > 0 ? restore : leaves) next.add(leaf);
return next;
}
/**
* The scope line under the Generate button, as the parts a component renders.
*
* Fenced inclusions are always restated BY NAME, never folded into the count.
* A number cannot be checked at a glance; "incl. Mood, PHQ-9" can, and the
* reading is the consent check.
*/
export interface ScopeSummary {
/** How many leaves are chosen in total. */
total: number;
/** How many groups contribute at least one leaf. */
groups: number;
/** i18n label keys for the chosen fenced leaves, in catalogue order. */
sensitiveLabelKeys: string[];
}
export function scopeSummary(selected: PanelSelection): ScopeSummary {
let total = 0;
let groups = 0;
const sensitiveLabelKeys: string[] = [];
for (const group of REPORT_GROUPS) {
const on = group.leaves.filter((leaf) => selected.has(leaf));
if (on.length === 0) continue;
groups += 1;
total += on.length;
if (group.id === SENSITIVE_GROUP_ID) {
sensitiveLabelKeys.push(...on.map(leafLabelKey));
}
}
return { total, groups, sensitiveLabelKeys };
}