diff --git a/messages/de.json b/messages/de.json index 7e30c8472..fa6e0151e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}" } } diff --git a/messages/en.json b/messages/en.json index 4adcf54d7..65b5cfbac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}" } } diff --git a/messages/es.json b/messages/es.json index 5b39c06ac..7fe4c4e81 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}" } } diff --git a/messages/fr.json b/messages/fr.json index c44e23a39..b2235e769 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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}" } } diff --git a/messages/it.json b/messages/it.json index c562f129e..177128bdc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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}" } } diff --git a/messages/pl.json b/messages/pl.json index 48821edd1..613298b0d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}" } } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 615a47f43..90643a7b7 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -110,6 +110,11 @@ export const GET = apiHandler(async () => { // formatters and the 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 diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 23ce8c6e4..dd02b9edd 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -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() { - {/* 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. */} - @@ -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(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 ( - - {busy ? ( - - ) : ( - - )} - {t("settings.sections.export.actions.download")} - - } - > - {error && ( -

- {error} -

- )} -
- ); -} diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index 45d9389a2..84b6ac4d9 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -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() { diff --git a/src/components/settings/health-record-export-controls.ts b/src/components/settings/health-record-export-controls.ts deleted file mode 100644 index e3c8721ff..000000000 --- a/src/components/settings/health-record-export-controls.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx deleted file mode 100644 index 9b16222f1..000000000 --- a/src/components/settings/health-record-export-panel.tsx +++ /dev/null @@ -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("pdf"); - const [days, setDays] = useState(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(DEFAULT_SECTIONS); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(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 ( - - - -
- {/* Format + range share a row on desktop so the panel opens - denser; on mobile they stack. */} -
-
- - {t("settings.healthRecord.format")} - -
- {EXPORT_FORMATS.map((f, index) => ( - - ))} -
-
- - {/* Date range */} -
- - setDays(Number(e.target.value))} - > - - - - - {/* 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 ? ( - - - {t("settings.healthRecord.documentsLink")} - - ) : null} -
-
- - {/* Practice name — PDF only */} - {isPdfLike && ( -
- - setPracticeName(e.target.value)} - maxLength={120} - placeholder={t("settings.healthRecord.practiceNamePlaceholder")} - /> -
- )} - - {/* 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). */} -
- - {t("settings.healthRecord.includedData")} - - - - {includedDataOpen && ( -
-
-

- {t("settings.healthRecord.groupVitals")} -

-
- toggle("weight")} - /> - toggle("bp")} - /> - toggle("pulse")} - /> - toggle("oxygenSaturation")} - /> - toggle("bodyFat")} - /> - toggle("bodyComposition")} - /> -
-
- -
-

- {t("settings.healthRecord.groupCardio")} -

-
- toggle("restingHeartRate")} - /> - toggle("hrv")} - /> - toggle("vo2max")} - /> -
-
- -
-

- {t("settings.healthRecord.groupActivity")} -

-
- toggle("steps")} - /> - toggle("distance")} - /> - toggle("sleep")} - /> -
-
- -
- toggle("glucose")} - /> - - toggle("bmi")} - /> - toggle("mood")} - /> - toggle("mentalHealthScreeners")} - /> - toggle("labs")} - /> - toggle("allergies")} - /> - toggle("familyHistory")} - /> -
- - {isPdfLike && ( - setIncludeCharts((v) => !v)} - /> - )} - {isPdfLike && ( - setIncludeAiSummary((v) => !v)} - /> - )} -
- )} -
- - {/* 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. */} -
- {isFhirLike ? ( -

- {t("settings.healthRecord.fhirNote")} -

- ) : ( - - )} - -
- - {error && ( -

- {t("settings.healthRecord.error", { code: error })} -

- )} -
-
- ); -} - -function ToggleRow({ - label, - checked, - onToggle, -}: { - label: string; - checked: boolean; - onToggle: () => void; -}) { - return ( - - ); -} diff --git a/src/components/settings/report-selection/group-row.tsx b/src/components/settings/report-selection/group-row.tsx new file mode 100644 index 000000000..a20cf0df2 --- /dev/null +++ b/src/components/settings/report-selection/group-row.tsx @@ -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; + id: ReportGroupId; + labelKey: string; + leaves: readonly ReportLeafId[]; + selected: ReadonlySet; + checkState: GroupCheckState; + count: { on: number; total: number }; + open: boolean; + panelId: string; + onToggleGroup: () => void; + onToggleLeaf: (leaf: ReportLeafId) => void; + onToggleOpen: () => void; +}) { + const label = t(labelKey); + return ( + +
+ + {label} + + {t("reportSelection.groupCount", { + on: count.on, + total: count.total, + })} + + +
+ {open ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/settings/report-selection/leaf-grid.tsx b/src/components/settings/report-selection/leaf-grid.tsx new file mode 100644 index 000000000..40b2d9561 --- /dev/null +++ b/src/components/settings/report-selection/leaf-grid.tsx @@ -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; + leaves: readonly ReportLeafId[]; + selected: ReadonlySet; + onToggle: (leaf: ReportLeafId) => void; +}) { + return ( +
+ {leaves.map((leaf) => { + const Icon = MEASUREMENT_TYPE_ICONS[leaf]; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/settings/report-selection/report-scope-picker.tsx b/src/components/settings/report-selection/report-scope-picker.tsx new file mode 100644 index 000000000..c72622686 --- /dev/null +++ b/src/components/settings/report-selection/report-scope-picker.tsx @@ -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; + onChange: (next: Set) => 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>( + 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>( + 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 ( +
+ {plainGroups.map((group) => ( + 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 ? ( +
+ +

+ {t(sensitiveGroup.labelKey)} +

+

+ {t("reportSelection.sensitiveHint")} +

+ +
+ ) : null} +
+ ); +} diff --git a/src/components/settings/report-selection/scope-summary.tsx b/src/components/settings/report-selection/scope-summary.tsx new file mode 100644 index 000000000..7887ffa71 --- /dev/null +++ b/src/components/settings/report-selection/scope-summary.tsx @@ -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; + selected: ReadonlySet; +}) { + const summary = scopeSummary(selected); + if (summary.total === 0) { + return ( +

+ {t("reportSelection.scopeNone")} +

+ ); + } + 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 ( +

+ {sensitive ? `${base} · ${sensitive}` : base} +

+ ); +} diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx new file mode 100644 index 000000000..d9839be16 --- /dev/null +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -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("pdf"); + const [days, setDays] = useState(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>( + () => new Set(STANDARD_TEMPLATE_LEAVES), + ); + const [seededUserId, setSeededUserId] = useState(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(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 ( + + + +
+ {/* Format + range share a row on desktop; on mobile they stack. */} +
+
+ + {t("settings.healthRecord.format")} + +
+ {EXPORT_FORMATS.map((f, index) => ( + + ))} +
+
+ +
+ + { + if (e.target.value === "custom") { + setCustomRange(true); + return; + } + setCustomRange(false); + setDays(Number(e.target.value)); + }} + > + {PRESET_RANGES.map((preset) => ( + + ))} + + + {/* Entry point into the document vault for the report period + (navigation only). Only rendered when the module is enabled. */} + {user?.modules?.inboundDocuments ? ( + + + {t("settings.healthRecord.documentsLink")} + + ) : null} +
+
+ + {customRange ? ( +
+
+ + +
+
+ + +
+
+ ) : null} + + {/* Practice name — PDF only */} + {isPdfLike && ( +
+ + setPracticeName(e.target.value)} + maxLength={120} + placeholder={t("settings.healthRecord.practiceNamePlaceholder")} + /> +
+ )} + +
+ + {t("settings.healthRecord.includedData")} + + + + {pickerOpen && ( +
+ {templateApplied ? ( +

+ {t("reportSelection.templateApplied", { + template: t("reportSelection.templateStandard"), + })} +

+ ) : null} + + {isPdfLike ? ( + + ) : null} +
+ )} +
+ + {/* The FHIR note and the generate action share the footer row. */} +
+
+ + {isFhirLike ? ( +

+ {t("settings.healthRecord.fhirNote")} +

+ ) : null} +
+ +
+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index 9f14a86a3..69cdbab55 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -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( 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 + >( + () => + new Set( + STANDARD_TEMPLATE_LEAVES.filter( + (leaf) => !SHARE_HIDDEN_LEAVES.includes(leaf), + ), + ), + ); const [pickerOpen, setPickerOpen] = useState(false); const [created, setCreated] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(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({ + {/* ── What the link contains ───────────────────────────────── */} + {!documentOnly ? ( +
+
+ +

+ {t("settings.sharing.scopeInsuranceExcluded")} +

+
+ + +
+ ) : null} + {/* ── Attach documents ─────────────────────────────────────── */}
@@ -384,7 +447,11 @@ export function ShareLinkCreateForm({
+ {/* 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 ? ( +

+ {t("settings.sharing.reselectReason")} +

+ ) : null}

{t("settings.sharing.accessCount")}: diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 9ff06273e..bde433d61 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -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 `` recipe: a Root that draws * the border + checked-state background, an Indicator slot that * shows the lucide `` when the box is checked. + * + * The Indicator also draws a `` 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" > -