From 28a87371240a0f220f30783d40c84307c709d7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 01:09:50 +0200 Subject: [PATCH] feat(anamnesis): life-circumstances card with AUDIT-C, screening hints and free-text take-over --- messages/de.json | 251 +++- messages/en.json | 251 +++- messages/es.json | 251 +++- messages/fr.json | 251 +++- messages/it.json | 251 +++- messages/ko.json | 251 +++- messages/pl.json | 251 +++- src/__tests__/v1341-locale-parity.test.ts | 4 +- src/app/api/dashboard/summary/route.ts | 3 + .../health-profile-facts-manager.test.tsx | 333 ----- .../records/ai-profile-inclusion-manager.tsx | 19 +- .../records/allergy-free-text-note.tsx | 3 + src/components/records/audit-c-panel.tsx | 248 ++++ src/components/records/conditions-manager.tsx | 3 + .../records/free-text-take-over.tsx | 240 ++++ .../records/health-profile-facts-manager.tsx | 426 ------- .../records/life-circumstances-manager.tsx | 1074 +++++++++++++++++ .../records/screening-hints-card.tsx | 80 ++ src/components/settings/anamnesis-section.tsx | 13 +- src/components/settings/vorsorge-section.tsx | 3 + .../ai/coach/snapshot-blocks/sleep-block.ts | 6 + src/lib/ai/coach/snapshot.ts | 2 + src/lib/ai/coach/tools/environment-read.ts | 56 + src/lib/command-palette/settings-cards.ts | 4 +- src/lib/insights/derived/sleep-rhythm.ts | 21 +- src/lib/profile/health-facts.ts | 7 +- src/lib/query-keys/coach.ts | 2 + 27 files changed, 3427 insertions(+), 877 deletions(-) delete mode 100644 src/components/records/__tests__/health-profile-facts-manager.test.tsx create mode 100644 src/components/records/audit-c-panel.tsx create mode 100644 src/components/records/free-text-take-over.tsx delete mode 100644 src/components/records/health-profile-facts-manager.tsx create mode 100644 src/components/records/life-circumstances-manager.tsx create mode 100644 src/components/records/screening-hints-card.tsx diff --git a/messages/de.json b/messages/de.json index 9fb9a2e08..d718f2aed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -384,7 +384,20 @@ "notes": "Weitere Hinweise" }, "courseRange": "Einnahmephase {start} bis {end}", - "courseSince": "Einnahmephase seit {start}" + "courseSince": "Einnahmephase seit {start}", + "lifePackYears": "Packungsjahre", + "lifeAuditC": "AUDIT-C (Screening)", + "lifeAuditCScore": "{score} von 12 am {date}.", + "lifeAuditCAbove": "Grenzwert {cutoff} erreicht oder überschritten ({source}).", + "lifeAuditCBelow": "Unter dem Grenzwert {cutoff} ({source}).", + "lifeAuditCNoCutoff": "Kein geschlechtsbezogener Grenzwert angewandt.", + "lifeActivity": "Körperliche Aktivität", + "lifeActivityValue": "{minutes} Minuten pro Woche, Selbstauskunft", + "lifeSitting": "Sitzen am Werktag", + "lifeSittingValue": "{hours} Stunden, Selbstauskunft", + "lifeWork": "Arbeit", + "lifeNightWork": "Nachtarbeit", + "lifeExposures": "Belastungen: {list}" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Lebensstil-Kontext", - "cardDescription": "Erfasse aktuelle Angaben, die die Einordnung deiner Gesundheitswerte verändern.", "smokingLabel": "Rauchstatus", "alcoholLabel": "Alkoholkonsummuster", "shiftLabel": "Arbeitszeitmodell", "notRecorded": "Nicht erfasst", - "unreadable": "Gespeicherter Wert konnte nicht gelesen werden", - "save": "Speichern", - "savedToast": "Lebensstil-Kontext gespeichert", - "savePartialError": "{kinds} konnte nicht gespeichert werden. Bitte erneut versuchen.", - "saveKindError": "Nicht gespeichert", - "loadError": "Lebensstil-Kontext konnte nicht geladen werden", "remove": "{kind} entfernen", - "removeShort": "Entfernen", - "removeConfirmTitle": "{kind} entfernen?", - "removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.", - "removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt", - "removeError": "{kind} konnte nicht entfernt werden", - "historyTitle": "Frühere Werte", - "to": "bis", "values": { "SMOKING_STATUS": { "NEVER": "Nie geraucht", @@ -11024,6 +11023,228 @@ "savedToast": "Notfalldaten gespeichert", "saveError": "Notfalldaten konnten nicht gespeichert werden.", "loadError": "Notfalldaten konnten nicht geladen werden." + }, + "life": { + "cardTitle": "Lebensumstände", + "cardDescription": "Rauchen, Alkohol, Bewegung, Arbeit, Gewohnheiten und Ziele, datiert und jeweils freiwillig.", + "loadError": "Deine Lebensumstände konnten nicht geladen werden.", + "noAnswer": "Keine Angabe", + "yes": "Ja", + "no": "Nein", + "validSince": "Gilt seit", + "month": "Monat", + "year": "Jahr", + "lastConfirmed": "Zuletzt bestätigt am {date}", + "notAnswered": "Noch nicht beantwortet", + "unreadable": "Diese Antwort kann auf diesem Server nicht gelesen werden.", + "save": "Abschnitt speichern", + "savedToast": "Abschnitt gespeichert", + "saveError": "Der Abschnitt konnte nicht gespeichert werden.", + "review": { + "question": "Stimmt das noch? Deine letzte Angabe ist über ein Jahr alt.", + "confirm": "Stimmt noch", + "confirmedToast": "Danke, als weiterhin gültig vermerkt", + "confirmError": "Die Bestätigung konnte nicht gespeichert werden." + }, + "ai": { + "label": "Diesen Abschnitt für die KI freigeben", + "shiftLabel": "Schichtarbeit für die KI freigeben", + "hint": "Aus heißt: Coach und Briefings sehen diese Angaben nie.", + "savedToast": "KI-Freigabe geändert", + "saveError": "Die KI-Freigabe konnte nicht gespeichert werden." + }, + "sections": { + "tobacco": { + "title": "Rauchen und Nikotin" + }, + "alcohol": { + "title": "Alkohol" + }, + "activity": { + "title": "Bewegung und Sitzen" + }, + "work": { + "title": "Arbeit und Rhythmus" + }, + "habits": { + "title": "Schlaf, Ernährung und Koffein" + }, + "goals": { + "title": "Ziele" + } + }, + "fields": { + "smokingStatus": "Rauchstatus", + "products": "Genutzte Produkte", + "cigarettesPerDay": "Zigaretten pro Tag", + "startYear": "Jahr des Rauchbeginns", + "quitMonth": "Rauchstopp", + "secondhandSmoke": "Regelmäßig Passivrauch", + "moderateDaysPerWeek": "Tage pro Woche mit mäßiger bis anstrengender Bewegung, etwa zügigem Gehen", + "moderateMinutesPerDay": "Minuten an diesen Tagen, im Schnitt", + "sittingHoursWorkday": "Stunden im Sitzen an einem Werktag", + "shiftSchedule": "Schichtarbeit", + "situation": "Arbeitssituation", + "physicalNature": "Art der Arbeit", + "nightWork": "Nachtarbeit", + "exposures": "Belastungen bei der Arbeit", + "freeWeekdays": "Freie Wochentage", + "napRegularly": "Regelmäßiger Mittagsschlaf", + "snoringReported": "Andere sagen, du schnarchst", + "dietPattern": "Ernährungsweise", + "dietSpecifics": "Besonderheiten", + "lateMealsPerWeek": "Mahlzeiten nach 21 Uhr pro Woche", + "fruitVegPortionsPerDay": "Portionen Obst und Gemüse pro Tag", + "caffeineDrinksPerDay": "Koffeinhaltige Getränke pro Tag", + "latestCaffeine": "Spätestes Koffein am Tag", + "goals": "Woran du arbeiten möchtest", + "goalsHint": "Höchstens drei." + }, + "values": { + "products": { + "cigarettes": "Zigaretten", + "cigars_pipe": "Zigarre oder Pfeife", + "e_cigarette": "E-Zigarette", + "heated_tobacco": "Tabakerhitzer", + "snus_pouches": "Snus oder Nikotinbeutel" + }, + "situation": { + "none": "Keine Erwerbsarbeit", + "indoor": "Überwiegend drinnen", + "outdoor": "Überwiegend draußen", + "mixed": "Drinnen und draußen" + }, + "physicalNature": { + "mostly_sitting": "Überwiegend sitzend", + "standing_walking": "Stehend oder gehend", + "heavy_physical": "Schwere körperliche Arbeit" + }, + "exposures": { + "heat": "Hitze", + "cold": "Kälte", + "dust": "Staub", + "fumes_solvents": "Dämpfe oder Lösungsmittel", + "noise": "Lärm" + }, + "dietPattern": { + "mixed": "Mischkost", + "vegetarian": "Vegetarisch", + "vegan": "Vegan", + "pescetarian": "Pescetarisch", + "other": "Andere" + }, + "dietSpecifics": { + "intermittent_fasting": "Intervallfasten", + "low_carb": "Kohlenhydratarm", + "prescribed_diet": "Ärztlich verordnete Diät" + }, + "latestCaffeine": { + "before_12": "Vor 12 Uhr", + "12_15": "12 bis 15 Uhr", + "15_18": "15 bis 18 Uhr", + "after_18": "Nach 18 Uhr" + }, + "goals": { + "weight": "Gewicht", + "blood_pressure": "Blutdruck", + "blood_sugar": "Blutzucker", + "sleep": "Schlaf", + "fitness": "Fitness", + "stress_recovery": "Stress und Erholung", + "medication_routine": "Medikamente regelmäßig nehmen", + "quit_smoking": "Rauchstopp", + "less_alcohol": "Weniger Alkohol" + }, + "weekdays": { + "1": "Mo", + "2": "Di", + "3": "Mi", + "4": "Do", + "5": "Fr", + "6": "Sa", + "7": "So" + } + }, + "derived": { + "packYears": "{value} Packungsjahre", + "packYearsMissing": "Für Packungsjahre fehlen Zigaretten pro Tag oder das Jahr des Rauchbeginns.", + "activityMinutes": "{value} Minuten pro Woche", + "meetsWho": "Damit erreichst du die WHO-Empfehlung von 150 Minuten pro Woche.", + "belowWho": "Das liegt unter der WHO-Empfehlung von 150 Minuten pro Woche.", + "measuredSteps": "Deine gemessenen Schritte haben Vorrang, diese Angabe ergänzt sie.", + "freeDaysDefault": "Ohne Angabe gelten Samstag und Sonntag als frei." + }, + "auditC": { + "intro": "Drei Fragen zu Alkohol aus dem WHO-AUDIT. Ein Screening, keine Diagnose.", + "start": "Fragen beantworten", + "again": "Erneut beantworten", + "offerDue": "Ein kurzer Blick einmal im Jahr genügt.", + "notValidated": "Für deine Sprache gibt es noch keine geprüfte Übersetzung, deshalb stehen die Fragen auf Englisch.", + "submit": "Antworten speichern", + "savedToast": "Antworten gespeichert", + "saveError": "Die Antworten konnten nicht gespeichert werden.", + "result": "{score} von 12 Punkten", + "above": "Am oder über dem Screening-Grenzwert von {cutoff}.", + "below": "Unter dem Screening-Grenzwert von {cutoff}.", + "noCutoff": "Ohne Angabe zum Geschlecht im Profil zeigen wir nur die Summe.", + "source": "Quelle: {source}", + "history": "Frühere Einträge", + "remove": "Eintrag entfernen", + "removeConfirmTitle": "Diesen Eintrag entfernen?", + "removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.", + "removedToast": "Eintrag entfernt", + "removeError": "Der Eintrag konnte nicht entfernt werden.", + "historyEntry": "{date}: {score} von 12" + }, + "takeOver": { + "conditionsButton": "In Zustandstagebuch übernehmen", + "allergiesButton": "In Allergien übernehmen", + "conditionsTitle": "Chronische Erkrankung anlegen", + "allergiesTitle": "Allergie anlegen", + "description": "Dein Freitext bleibt, bis du ihn selbst leerst.", + "freeTextLabel": "Dein Freitext", + "conditionLabel": "Erkrankung", + "onset": "Seit", + "save": "Anlegen", + "savedToast": "Eintrag angelegt", + "saveError": "Der Eintrag konnte nicht angelegt werden.", + "clearQuestion": "Der Eintrag ist gespeichert. Freitext jetzt leeren?", + "clear": "Freitext leeren", + "keep": "Behalten", + "clearedToast": "Freitext geleert", + "clearError": "Der Freitext konnte nicht geleert werden." + }, + "screening": { + "cardTitle": "Hinweise zur Früherkennung", + "cardDescription": "Aus deinen Lebensumständen, mit Regel und Land, aus dem sie stammt.", + "noReminder": "Nur ein Hinweis; eine Erinnerung legst du bei Bedarf selbst an.", + "source": "Quelle: {source}", + "titles": { + "lung_ldct": "Niedrigdosis-CT zur Lungenkrebs-Früherkennung", + "aaa_ultrasound": "Ultraschall der Bauchschlagader" + }, + "status": { + "suggested": "Könnte für dich gelten", + "not_eligible": "Gilt nicht für dich", + "unknown": "Zu wenige Angaben" + }, + "reasons": { + "never_smoked": "Du hast nie geraucht.", + "missing_birth_date": "Dein Geburtsdatum fehlt.", + "age_out_of_range": "Dein Alter liegt außerhalb des Bereichs.", + "missing_smoking_status": "Dein Rauchstatus fehlt.", + "no_cigarettes": "Die Regel zählt nur Zigaretten.", + "missing_smoking_history": "Zigaretten pro Tag oder das Jahr des Rauchbeginns fehlen.", + "pack_years_below_15": "Weniger als 15 Packungsjahre.", + "pack_years_below_20": "Weniger als 20 Packungsjahre.", + "smoked_years_below_25": "Weniger als 25 Jahre geraucht.", + "missing_quit_date": "Das Datum des Rauchstopps fehlt.", + "quit_over_10_years": "Der Rauchstopp liegt über 10 Jahre zurück.", + "quit_over_15_years": "Der Rauchstopp liegt über 15 Jahre zurück.", + "missing_sex": "Im Profil fehlt das Geschlecht.", + "sex_not_male": "Die Regel gilt für Männer." + } + } } }, "environment": { diff --git a/messages/en.json b/messages/en.json index dfaf30239..dbb8fa089 100644 --- a/messages/en.json +++ b/messages/en.json @@ -384,7 +384,20 @@ "immunizationsColDose": "Dose", "immunizationsColLot": "Lot", "courseRange": "Course {start} to {end}", - "courseSince": "Course since {start}" + "courseSince": "Course since {start}", + "lifePackYears": "Pack-years", + "lifeAuditC": "AUDIT-C (screening)", + "lifeAuditCScore": "{score} of 12 on {date}.", + "lifeAuditCAbove": "At or above the cut-off of {cutoff} ({source}).", + "lifeAuditCBelow": "Below the cut-off of {cutoff} ({source}).", + "lifeAuditCNoCutoff": "No sex-specific cut-off applied.", + "lifeActivity": "Physical activity", + "lifeActivityValue": "{minutes} minutes a week, self-reported", + "lifeSitting": "Sitting on a workday", + "lifeSittingValue": "{hours} hours, self-reported", + "lifeWork": "Work", + "lifeNightWork": "night work", + "lifeExposures": "exposures: {list}" }, "errorBoundary": { "title": "Something went wrong", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Lifestyle context", - "cardDescription": "Record the current facts that change how your health readings should be interpreted.", "smokingLabel": "Smoking status", "alcoholLabel": "Alcohol pattern", "shiftLabel": "Work schedule", "notRecorded": "Not recorded", - "unreadable": "Stored value could not be read", - "save": "Save", - "savedToast": "Lifestyle context saved", - "savePartialError": "Couldn't save {kinds}. Please try again.", - "saveKindError": "Not saved", - "loadError": "Could not load lifestyle context", "remove": "Remove {kind}", - "removeShort": "Remove", - "removeConfirmTitle": "Remove {kind}?", - "removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.", - "removedToast": "{kind} removed from current lifestyle context", - "removeError": "Could not remove {kind}", - "historyTitle": "Previous values", - "to": "to", "values": { "SMOKING_STATUS": { "NEVER": "Never smoked", @@ -11024,6 +11023,228 @@ "savedToast": "Emergency information saved", "saveError": "Couldn't save your emergency information.", "loadError": "Couldn't load your emergency information." + }, + "life": { + "cardTitle": "Life circumstances", + "cardDescription": "Smoking, alcohol, activity, work, habits and goals, dated and each optional.", + "loadError": "Your life circumstances could not be loaded.", + "noAnswer": "No answer", + "yes": "Yes", + "no": "No", + "validSince": "Valid since", + "month": "Month", + "year": "Year", + "lastConfirmed": "Last confirmed on {date}", + "notAnswered": "Not answered yet", + "unreadable": "This answer cannot be read on this server.", + "save": "Save section", + "savedToast": "Section saved", + "saveError": "The section could not be saved.", + "review": { + "question": "Is this still true? Your last answer is more than a year old.", + "confirm": "Still true", + "confirmedToast": "Thanks, marked as still true", + "confirmError": "The confirmation could not be saved." + }, + "ai": { + "label": "Share this section with the AI", + "shiftLabel": "Share shift work with the AI", + "hint": "Off means the Coach and the briefings never see these answers.", + "savedToast": "AI sharing updated", + "saveError": "The AI setting could not be saved." + }, + "sections": { + "tobacco": { + "title": "Smoking and nicotine" + }, + "alcohol": { + "title": "Alcohol" + }, + "activity": { + "title": "Activity and sitting" + }, + "work": { + "title": "Work and rhythm" + }, + "habits": { + "title": "Sleep, diet and caffeine" + }, + "goals": { + "title": "Goals" + } + }, + "fields": { + "smokingStatus": "Smoking status", + "products": "Products used", + "cigarettesPerDay": "Cigarettes per day", + "startYear": "Year you started", + "quitMonth": "When you stopped", + "secondhandSmoke": "Regular secondhand smoke", + "moderateDaysPerWeek": "Days per week with moderate to strenuous activity, such as brisk walking", + "moderateMinutesPerDay": "Minutes on those days, on average", + "sittingHoursWorkday": "Hours sitting on a workday", + "shiftSchedule": "Shift work", + "situation": "Work situation", + "physicalNature": "Kind of work", + "nightWork": "Night work", + "exposures": "Exposures at work", + "freeWeekdays": "Free days of the week", + "napRegularly": "Regular nap", + "snoringReported": "Others say you snore", + "dietPattern": "Diet", + "dietSpecifics": "Specifics", + "lateMealsPerWeek": "Meals after 9 pm per week", + "fruitVegPortionsPerDay": "Portions of fruit and vegetables per day", + "caffeineDrinksPerDay": "Caffeinated drinks per day", + "latestCaffeine": "Latest caffeine of the day", + "goals": "What you want to work on", + "goalsHint": "Up to three." + }, + "values": { + "products": { + "cigarettes": "Cigarettes", + "cigars_pipe": "Cigars or pipe", + "e_cigarette": "E-cigarette", + "heated_tobacco": "Heated tobacco", + "snus_pouches": "Snus or nicotine pouches" + }, + "situation": { + "none": "Not in paid work", + "indoor": "Mostly indoors", + "outdoor": "Mostly outdoors", + "mixed": "Indoors and outdoors" + }, + "physicalNature": { + "mostly_sitting": "Mostly sitting", + "standing_walking": "Standing or walking", + "heavy_physical": "Heavy physical work" + }, + "exposures": { + "heat": "Heat", + "cold": "Cold", + "dust": "Dust", + "fumes_solvents": "Fumes or solvents", + "noise": "Noise" + }, + "dietPattern": { + "mixed": "Mixed diet", + "vegetarian": "Vegetarian", + "vegan": "Vegan", + "pescetarian": "Pescetarian", + "other": "Other" + }, + "dietSpecifics": { + "intermittent_fasting": "Intermittent fasting", + "low_carb": "Low carb", + "prescribed_diet": "Diet prescribed by a doctor" + }, + "latestCaffeine": { + "before_12": "Before noon", + "12_15": "Noon to 3 pm", + "15_18": "3 to 6 pm", + "after_18": "After 6 pm" + }, + "goals": { + "weight": "Weight", + "blood_pressure": "Blood pressure", + "blood_sugar": "Blood sugar", + "sleep": "Sleep", + "fitness": "Fitness", + "stress_recovery": "Stress and recovery", + "medication_routine": "Taking medication regularly", + "quit_smoking": "Stop smoking", + "less_alcohol": "Drink less alcohol" + }, + "weekdays": { + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat", + "7": "Sun" + } + }, + "derived": { + "packYears": "{value} pack-years", + "packYearsMissing": "Pack-years need cigarettes per day and the year you started.", + "activityMinutes": "{value} minutes a week", + "meetsWho": "That meets the WHO recommendation of 150 minutes a week.", + "belowWho": "That is below the WHO recommendation of 150 minutes a week.", + "measuredSteps": "Your measured steps come first; this answer adds context.", + "freeDaysDefault": "Without an answer, Saturday and Sunday count as free days." + }, + "auditC": { + "intro": "Three questions on alcohol from the WHO AUDIT. A screening, not a diagnosis.", + "start": "Answer the questions", + "again": "Answer again", + "offerDue": "A quick look once a year is enough.", + "notValidated": "There is no validated translation for your language yet, so the questions are shown in English.", + "submit": "Save answers", + "savedToast": "Answers saved", + "saveError": "The answers could not be saved.", + "result": "{score} of 12 points", + "above": "At or above the screening cut-off of {cutoff}.", + "below": "Below the screening cut-off of {cutoff}.", + "noCutoff": "Without a sex in your profile only the sum is shown.", + "source": "Source: {source}", + "history": "Earlier entries", + "remove": "Remove entry", + "removeConfirmTitle": "Remove this entry?", + "removeConfirmBody": "The entry leaves your history. This cannot be undone here.", + "removedToast": "Entry removed", + "removeError": "The entry could not be removed.", + "historyEntry": "{date}: {score} of 12" + }, + "takeOver": { + "conditionsButton": "Add to condition journal", + "allergiesButton": "Add to allergies", + "conditionsTitle": "Add a chronic condition", + "allergiesTitle": "Add an allergy", + "description": "Your free text stays as it is until you clear it yourself.", + "freeTextLabel": "Your free text", + "conditionLabel": "Condition", + "onset": "Since", + "save": "Add", + "savedToast": "Entry added", + "saveError": "The entry could not be added.", + "clearQuestion": "The entry is saved. Clear the free text now?", + "clear": "Clear free text", + "keep": "Keep it", + "clearedToast": "Free text cleared", + "clearError": "The free text could not be cleared." + }, + "screening": { + "cardTitle": "Screening hints", + "cardDescription": "From your life circumstances, with the rule and the country it comes from.", + "noReminder": "This is a hint only; set a reminder yourself if you want one.", + "source": "Source: {source}", + "titles": { + "lung_ldct": "Low-dose CT for lung cancer", + "aaa_ultrasound": "Ultrasound of the abdominal aorta" + }, + "status": { + "suggested": "May apply to you", + "not_eligible": "Does not apply to you", + "unknown": "Not enough information" + }, + "reasons": { + "never_smoked": "You have never smoked.", + "missing_birth_date": "Your birth date is missing.", + "age_out_of_range": "Your age is outside the range.", + "missing_smoking_status": "Your smoking status is missing.", + "no_cigarettes": "The rule counts cigarettes only.", + "missing_smoking_history": "Cigarettes per day or the start year is missing.", + "pack_years_below_15": "Fewer than 15 pack-years.", + "pack_years_below_20": "Fewer than 20 pack-years.", + "smoked_years_below_25": "Fewer than 25 years of smoking.", + "missing_quit_date": "The date you stopped is missing.", + "quit_over_10_years": "You stopped more than 10 years ago.", + "quit_over_15_years": "You stopped more than 15 years ago.", + "missing_sex": "Your sex is missing in the profile.", + "sex_not_male": "The rule applies to men." + } + } } }, "environment": { diff --git a/messages/es.json b/messages/es.json index 33e992c02..dec9728fc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -384,7 +384,20 @@ "notes": "Notas adicionales" }, "courseRange": "Tratamiento del {start} al {end}", - "courseSince": "Tratamiento desde el {start}" + "courseSince": "Tratamiento desde el {start}", + "lifePackYears": "Paquetes-año", + "lifeAuditC": "AUDIT-C (cribado)", + "lifeAuditCScore": "{score} de 12 el {date}.", + "lifeAuditCAbove": "En o por encima del punto de corte de {cutoff} ({source}).", + "lifeAuditCBelow": "Por debajo del punto de corte de {cutoff} ({source}).", + "lifeAuditCNoCutoff": "Sin punto de corte específico por sexo.", + "lifeActivity": "Actividad física", + "lifeActivityValue": "{minutes} minutos a la semana, declarado", + "lifeSitting": "Tiempo sentado en día laborable", + "lifeSittingValue": "{hours} horas, declarado", + "lifeWork": "Trabajo", + "lifeNightWork": "trabajo nocturno", + "lifeExposures": "exposiciones: {list}" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Contexto del estilo de vida", - "cardDescription": "Registra los datos actuales que cambian la interpretación de tus mediciones de salud.", "smokingLabel": "Tabaquismo", "alcoholLabel": "Patrón de consumo de alcohol", "shiftLabel": "Horario de trabajo", "notRecorded": "No registrado", - "unreadable": "No se pudo leer el valor guardado", - "save": "Guardar", - "savedToast": "Contexto del estilo de vida guardado", - "savePartialError": "No se pudo guardar {kinds}. Inténtalo de nuevo.", - "saveKindError": "No guardado", - "loadError": "No se pudo cargar el contexto del estilo de vida", "remove": "Eliminar {kind}", - "removeShort": "Quitar", - "removeConfirmTitle": "¿Eliminar {kind}?", - "removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.", - "removedToast": "{kind} eliminado del contexto actual del estilo de vida", - "removeError": "No se pudo eliminar {kind}", - "historyTitle": "Valores anteriores", - "to": "a", "values": { "SMOKING_STATUS": { "NEVER": "Nunca ha fumado", @@ -11024,6 +11023,228 @@ "savedToast": "Información de emergencia guardada", "saveError": "No se pudo guardar la información de emergencia.", "loadError": "No se pudo cargar la información de emergencia." + }, + "life": { + "cardTitle": "Circunstancias de vida", + "cardDescription": "Tabaco, alcohol, actividad, trabajo, hábitos y objetivos, con fecha y siempre opcionales.", + "loadError": "No se pudieron cargar tus circunstancias de vida.", + "noAnswer": "Sin respuesta", + "yes": "Sí", + "no": "No", + "validSince": "Válido desde", + "month": "Mes", + "year": "Año", + "lastConfirmed": "Confirmado por última vez el {date}", + "notAnswered": "Aún sin responder", + "unreadable": "Esta respuesta no se puede leer en este servidor.", + "save": "Guardar sección", + "savedToast": "Sección guardada", + "saveError": "No se pudo guardar la sección.", + "review": { + "question": "¿Sigue siendo cierto? Tu última respuesta tiene más de un año.", + "confirm": "Sigue siendo cierto", + "confirmedToast": "Gracias, marcado como vigente", + "confirmError": "No se pudo guardar la confirmación." + }, + "ai": { + "label": "Compartir esta sección con la IA", + "shiftLabel": "Compartir el trabajo por turnos con la IA", + "hint": "Desactivado, el Coach y los resúmenes nunca ven estas respuestas.", + "savedToast": "Uso por la IA actualizado", + "saveError": "No se pudo guardar el ajuste de la IA." + }, + "sections": { + "tobacco": { + "title": "Tabaco y nicotina" + }, + "alcohol": { + "title": "Alcohol" + }, + "activity": { + "title": "Actividad y tiempo sentado" + }, + "work": { + "title": "Trabajo y ritmo" + }, + "habits": { + "title": "Sueño, alimentación y cafeína" + }, + "goals": { + "title": "Objetivos" + } + }, + "fields": { + "smokingStatus": "Consumo de tabaco", + "products": "Productos que usas", + "cigarettesPerDay": "Cigarrillos al día", + "startYear": "Año en que empezaste", + "quitMonth": "Cuándo lo dejaste", + "secondhandSmoke": "Humo ajeno con frecuencia", + "moderateDaysPerWeek": "Días por semana con actividad moderada o intensa, como caminar a paso rápido", + "moderateMinutesPerDay": "Minutos esos días, de media", + "sittingHoursWorkday": "Horas sentado en un día laborable", + "shiftSchedule": "Trabajo por turnos", + "situation": "Situación laboral", + "physicalNature": "Tipo de trabajo", + "nightWork": "Trabajo nocturno", + "exposures": "Exposiciones en el trabajo", + "freeWeekdays": "Días libres de la semana", + "napRegularly": "Siesta habitual", + "snoringReported": "Otros dicen que roncas", + "dietPattern": "Alimentación", + "dietSpecifics": "Particularidades", + "lateMealsPerWeek": "Comidas después de las 21 h por semana", + "fruitVegPortionsPerDay": "Raciones de fruta y verdura al día", + "caffeineDrinksPerDay": "Bebidas con cafeína al día", + "latestCaffeine": "Última cafeína del día", + "goals": "En qué quieres trabajar", + "goalsHint": "Como máximo tres." + }, + "values": { + "products": { + "cigarettes": "Cigarrillos", + "cigars_pipe": "Puros o pipa", + "e_cigarette": "Cigarrillo electrónico", + "heated_tobacco": "Tabaco calentado", + "snus_pouches": "Snus o bolsas de nicotina" + }, + "situation": { + "none": "Sin trabajo remunerado", + "indoor": "Sobre todo en interiores", + "outdoor": "Sobre todo al aire libre", + "mixed": "Dentro y fuera" + }, + "physicalNature": { + "mostly_sitting": "Sobre todo sentado", + "standing_walking": "De pie o caminando", + "heavy_physical": "Trabajo físico pesado" + }, + "exposures": { + "heat": "Calor", + "cold": "Frío", + "dust": "Polvo", + "fumes_solvents": "Humos o disolventes", + "noise": "Ruido" + }, + "dietPattern": { + "mixed": "Dieta mixta", + "vegetarian": "Vegetariana", + "vegan": "Vegana", + "pescetarian": "Pescetariana", + "other": "Otra" + }, + "dietSpecifics": { + "intermittent_fasting": "Ayuno intermitente", + "low_carb": "Baja en carbohidratos", + "prescribed_diet": "Dieta prescrita por el médico" + }, + "latestCaffeine": { + "before_12": "Antes de las 12 h", + "12_15": "De 12 a 15 h", + "15_18": "De 15 a 18 h", + "after_18": "Después de las 18 h" + }, + "goals": { + "weight": "Peso", + "blood_pressure": "Presión arterial", + "blood_sugar": "Glucosa", + "sleep": "Sueño", + "fitness": "Forma física", + "stress_recovery": "Estrés y recuperación", + "medication_routine": "Tomar la medicación con regularidad", + "quit_smoking": "Dejar de fumar", + "less_alcohol": "Beber menos alcohol" + }, + "weekdays": { + "1": "lun", + "2": "mar", + "3": "mié", + "4": "jue", + "5": "vie", + "6": "sáb", + "7": "dom" + } + }, + "derived": { + "packYears": "{value} paquetes-año", + "packYearsMissing": "Para los paquetes-año faltan los cigarrillos al día o el año en que empezaste.", + "activityMinutes": "{value} minutos a la semana", + "meetsWho": "Cumple la recomendación de la OMS de 150 minutos semanales.", + "belowWho": "Está por debajo de la recomendación de la OMS de 150 minutos semanales.", + "measuredSteps": "Tus pasos medidos tienen prioridad; esta respuesta aporta contexto.", + "freeDaysDefault": "Sin respuesta, el sábado y el domingo cuentan como días libres." + }, + "auditC": { + "intro": "Tres preguntas sobre alcohol del AUDIT de la OMS. Un cribado, no un diagnóstico.", + "start": "Responder las preguntas", + "again": "Responder de nuevo", + "offerDue": "Basta con revisarlo una vez al año.", + "notValidated": "Aún no hay una traducción validada para tu idioma, por eso las preguntas aparecen en inglés.", + "submit": "Guardar respuestas", + "savedToast": "Respuestas guardadas", + "saveError": "No se pudieron guardar las respuestas.", + "result": "{score} de 12 puntos", + "above": "En o por encima del punto de corte de cribado de {cutoff}.", + "below": "Por debajo del punto de corte de cribado de {cutoff}.", + "noCutoff": "Sin sexo en tu perfil solo se muestra la suma.", + "source": "Fuente: {source}", + "history": "Entradas anteriores", + "remove": "Eliminar entrada", + "removeConfirmTitle": "¿Eliminar esta entrada?", + "removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.", + "removedToast": "Entrada eliminada", + "removeError": "No se pudo eliminar la entrada.", + "historyEntry": "{date}: {score} de 12" + }, + "takeOver": { + "conditionsButton": "Pasar al diario de afecciones", + "allergiesButton": "Pasar a alergias", + "conditionsTitle": "Añadir una enfermedad crónica", + "allergiesTitle": "Añadir una alergia", + "description": "Tu texto libre se queda como está hasta que lo borres tú.", + "freeTextLabel": "Tu texto libre", + "conditionLabel": "Enfermedad", + "onset": "Desde", + "save": "Añadir", + "savedToast": "Entrada añadida", + "saveError": "No se pudo añadir la entrada.", + "clearQuestion": "La entrada está guardada. ¿Borrar ahora el texto libre?", + "clear": "Borrar texto libre", + "keep": "Conservarlo", + "clearedToast": "Texto libre borrado", + "clearError": "No se pudo borrar el texto libre." + }, + "screening": { + "cardTitle": "Indicaciones de cribado", + "cardDescription": "A partir de tus circunstancias de vida, con la norma y el país de origen.", + "noReminder": "Es solo una indicación; si quieres un recordatorio, créalo tú.", + "source": "Fuente: {source}", + "titles": { + "lung_ldct": "TC de baja dosis para cáncer de pulmón", + "aaa_ultrasound": "Ecografía de la aorta abdominal" + }, + "status": { + "suggested": "Podría aplicarse a ti", + "not_eligible": "No se aplica a ti", + "unknown": "Falta información" + }, + "reasons": { + "never_smoked": "Nunca has fumado.", + "missing_birth_date": "Falta tu fecha de nacimiento.", + "age_out_of_range": "Tu edad está fuera del rango.", + "missing_smoking_status": "Falta tu consumo de tabaco.", + "no_cigarettes": "La norma solo cuenta cigarrillos.", + "missing_smoking_history": "Faltan los cigarrillos al día o el año de inicio.", + "pack_years_below_15": "Menos de 15 paquetes-año.", + "pack_years_below_20": "Menos de 20 paquetes-año.", + "smoked_years_below_25": "Menos de 25 años fumando.", + "missing_quit_date": "Falta la fecha en que lo dejaste.", + "quit_over_10_years": "Lo dejaste hace más de 10 años.", + "quit_over_15_years": "Lo dejaste hace más de 15 años.", + "missing_sex": "Falta el sexo en tu perfil.", + "sex_not_male": "La norma se aplica a hombres." + } + } } }, "environment": { diff --git a/messages/fr.json b/messages/fr.json index 66e399825..7914bc9aa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -384,7 +384,20 @@ "notes": "Notes complémentaires" }, "courseRange": "Traitement du {start} au {end}", - "courseSince": "Traitement depuis le {start}" + "courseSince": "Traitement depuis le {start}", + "lifePackYears": "Paquets-années", + "lifeAuditC": "AUDIT-C (dépistage)", + "lifeAuditCScore": "{score} sur 12 le {date}.", + "lifeAuditCAbove": "Au seuil de {cutoff} ou au-dessus ({source}).", + "lifeAuditCBelow": "Sous le seuil de {cutoff} ({source}).", + "lifeAuditCNoCutoff": "Aucun seuil propre au sexe appliqué.", + "lifeActivity": "Activité physique", + "lifeActivityValue": "{minutes} minutes par semaine, déclaré", + "lifeSitting": "Temps assis un jour de travail", + "lifeSittingValue": "{hours} heures, déclaré", + "lifeWork": "Travail", + "lifeNightWork": "travail de nuit", + "lifeExposures": "expositions : {list}" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Contexte du mode de vie", - "cardDescription": "Indiquez les faits actuels qui modifient l'interprétation de vos mesures de santé.", "smokingLabel": "Tabagisme", "alcoholLabel": "Habitudes de consommation d'alcool", "shiftLabel": "Organisation du travail", "notRecorded": "Non renseigné", - "unreadable": "La valeur enregistrée n'a pas pu être lue", - "save": "Enregistrer", - "savedToast": "Contexte du mode de vie enregistré", - "savePartialError": "Impossible d'enregistrer {kinds}. Veuillez réessayer.", - "saveKindError": "Non enregistré", - "loadError": "Impossible de charger le contexte du mode de vie", "remove": "Supprimer : {kind}", - "removeShort": "Supprimer", - "removeConfirmTitle": "Supprimer : {kind} ?", - "removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.", - "removedToast": "{kind} supprimé du contexte actuel du mode de vie", - "removeError": "Impossible de supprimer : {kind}", - "historyTitle": "Valeurs précédentes", - "to": "à", "values": { "SMOKING_STATUS": { "NEVER": "N'a jamais fumé", @@ -11024,6 +11023,228 @@ "savedToast": "Informations d'urgence enregistrées", "saveError": "Impossible d'enregistrer vos informations d'urgence.", "loadError": "Impossible de charger vos informations d'urgence." + }, + "life": { + "cardTitle": "Conditions de vie", + "cardDescription": "Tabac, alcool, activité, travail, habitudes et objectifs, datés et tous facultatifs.", + "loadError": "Impossible de charger vos conditions de vie.", + "noAnswer": "Pas de réponse", + "yes": "Oui", + "no": "Non", + "validSince": "Valable depuis", + "month": "Mois", + "year": "Année", + "lastConfirmed": "Confirmé pour la dernière fois le {date}", + "notAnswered": "Pas encore répondu", + "unreadable": "Cette réponse ne peut pas être lue sur ce serveur.", + "save": "Enregistrer la section", + "savedToast": "Section enregistrée", + "saveError": "Impossible d'enregistrer la section.", + "review": { + "question": "Est-ce toujours vrai ? Votre dernière réponse date de plus d'un an.", + "confirm": "Toujours vrai", + "confirmedToast": "Merci, noté comme toujours valable", + "confirmError": "Impossible d'enregistrer la confirmation." + }, + "ai": { + "label": "Partager cette section avec l'IA", + "shiftLabel": "Partager le travail posté avec l'IA", + "hint": "Désactivé, le Coach et les synthèses ne voient jamais ces réponses.", + "savedToast": "Partage avec l'IA mis à jour", + "saveError": "Impossible d'enregistrer le réglage de l'IA." + }, + "sections": { + "tobacco": { + "title": "Tabac et nicotine" + }, + "alcohol": { + "title": "Alcool" + }, + "activity": { + "title": "Activité et position assise" + }, + "work": { + "title": "Travail et rythme" + }, + "habits": { + "title": "Sommeil, alimentation et caféine" + }, + "goals": { + "title": "Objectifs" + } + }, + "fields": { + "smokingStatus": "Statut tabagique", + "products": "Produits utilisés", + "cigarettesPerDay": "Cigarettes par jour", + "startYear": "Année de début", + "quitMonth": "Date d'arrêt", + "secondhandSmoke": "Fumée passive régulière", + "moderateDaysPerWeek": "Jours par semaine avec une activité modérée à intense, comme la marche rapide", + "moderateMinutesPerDay": "Minutes ces jours-là, en moyenne", + "sittingHoursWorkday": "Heures assis un jour de travail", + "shiftSchedule": "Travail posté", + "situation": "Situation de travail", + "physicalNature": "Type de travail", + "nightWork": "Travail de nuit", + "exposures": "Expositions au travail", + "freeWeekdays": "Jours libres de la semaine", + "napRegularly": "Sieste régulière", + "snoringReported": "D'autres disent que vous ronflez", + "dietPattern": "Alimentation", + "dietSpecifics": "Particularités", + "lateMealsPerWeek": "Repas après 21 h par semaine", + "fruitVegPortionsPerDay": "Portions de fruits et légumes par jour", + "caffeineDrinksPerDay": "Boissons caféinées par jour", + "latestCaffeine": "Dernière caféine de la journée", + "goals": "Ce sur quoi vous voulez travailler", + "goalsHint": "Trois au maximum." + }, + "values": { + "products": { + "cigarettes": "Cigarettes", + "cigars_pipe": "Cigare ou pipe", + "e_cigarette": "Cigarette électronique", + "heated_tobacco": "Tabac chauffé", + "snus_pouches": "Snus ou sachets de nicotine" + }, + "situation": { + "none": "Sans activité rémunérée", + "indoor": "Surtout en intérieur", + "outdoor": "Surtout en extérieur", + "mixed": "Intérieur et extérieur" + }, + "physicalNature": { + "mostly_sitting": "Surtout assis", + "standing_walking": "Debout ou en marchant", + "heavy_physical": "Travail physique lourd" + }, + "exposures": { + "heat": "Chaleur", + "cold": "Froid", + "dust": "Poussière", + "fumes_solvents": "Fumées ou solvants", + "noise": "Bruit" + }, + "dietPattern": { + "mixed": "Alimentation mixte", + "vegetarian": "Végétarienne", + "vegan": "Végane", + "pescetarian": "Pescétarienne", + "other": "Autre" + }, + "dietSpecifics": { + "intermittent_fasting": "Jeûne intermittent", + "low_carb": "Pauvre en glucides", + "prescribed_diet": "Régime prescrit par un médecin" + }, + "latestCaffeine": { + "before_12": "Avant midi", + "12_15": "De midi à 15 h", + "15_18": "De 15 h à 18 h", + "after_18": "Après 18 h" + }, + "goals": { + "weight": "Poids", + "blood_pressure": "Tension artérielle", + "blood_sugar": "Glycémie", + "sleep": "Sommeil", + "fitness": "Forme physique", + "stress_recovery": "Stress et récupération", + "medication_routine": "Prendre ses médicaments régulièrement", + "quit_smoking": "Arrêter de fumer", + "less_alcohol": "Boire moins d'alcool" + }, + "weekdays": { + "1": "lun.", + "2": "mar.", + "3": "mer.", + "4": "jeu.", + "5": "ven.", + "6": "sam.", + "7": "dim." + } + }, + "derived": { + "packYears": "{value} paquets-années", + "packYearsMissing": "Pour les paquets-années, il manque les cigarettes par jour ou l'année de début.", + "activityMinutes": "{value} minutes par semaine", + "meetsWho": "Cela atteint la recommandation de l'OMS de 150 minutes par semaine.", + "belowWho": "C'est en dessous de la recommandation de l'OMS de 150 minutes par semaine.", + "measuredSteps": "Vos pas mesurés passent en premier ; cette réponse apporte du contexte.", + "freeDaysDefault": "Sans réponse, le samedi et le dimanche comptent comme jours libres." + }, + "auditC": { + "intro": "Trois questions sur l'alcool tirées de l'AUDIT de l'OMS. Un dépistage, pas un diagnostic.", + "start": "Répondre aux questions", + "again": "Répondre à nouveau", + "offerDue": "Un rapide coup d'œil une fois par an suffit.", + "notValidated": "Il n'existe pas encore de traduction validée pour votre langue, les questions sont donc en anglais.", + "submit": "Enregistrer les réponses", + "savedToast": "Réponses enregistrées", + "saveError": "Impossible d'enregistrer les réponses.", + "result": "{score} points sur 12", + "above": "Au seuil de dépistage de {cutoff} ou au-dessus.", + "below": "Sous le seuil de dépistage de {cutoff}.", + "noCutoff": "Sans sexe dans votre profil, seule la somme est affichée.", + "source": "Source : {source}", + "history": "Entrées précédentes", + "remove": "Supprimer l'entrée", + "removeConfirmTitle": "Supprimer cette entrée ?", + "removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.", + "removedToast": "Entrée supprimée", + "removeError": "Impossible de supprimer l'entrée.", + "historyEntry": "{date} : {score} sur 12" + }, + "takeOver": { + "conditionsButton": "Reprendre dans le journal des affections", + "allergiesButton": "Reprendre dans les allergies", + "conditionsTitle": "Ajouter une maladie chronique", + "allergiesTitle": "Ajouter une allergie", + "description": "Votre texte libre reste tel quel jusqu'à ce que vous l'effaciez vous-même.", + "freeTextLabel": "Votre texte libre", + "conditionLabel": "Maladie", + "onset": "Depuis", + "save": "Ajouter", + "savedToast": "Entrée ajoutée", + "saveError": "Impossible d'ajouter l'entrée.", + "clearQuestion": "L'entrée est enregistrée. Effacer le texte libre maintenant ?", + "clear": "Effacer le texte libre", + "keep": "Le garder", + "clearedToast": "Texte libre effacé", + "clearError": "Impossible d'effacer le texte libre." + }, + "screening": { + "cardTitle": "Indications de dépistage", + "cardDescription": "Tirées de vos conditions de vie, avec la règle et le pays d'origine.", + "noReminder": "Ce n'est qu'une indication ; créez vous-même un rappel si vous en voulez un.", + "source": "Source : {source}", + "titles": { + "lung_ldct": "Scanner faible dose pour le cancer du poumon", + "aaa_ultrasound": "Échographie de l'aorte abdominale" + }, + "status": { + "suggested": "Pourrait vous concerner", + "not_eligible": "Ne vous concerne pas", + "unknown": "Informations insuffisantes" + }, + "reasons": { + "never_smoked": "Vous n'avez jamais fumé.", + "missing_birth_date": "Votre date de naissance manque.", + "age_out_of_range": "Votre âge est hors de la tranche.", + "missing_smoking_status": "Votre statut tabagique manque.", + "no_cigarettes": "La règle ne compte que les cigarettes.", + "missing_smoking_history": "Il manque les cigarettes par jour ou l'année de début.", + "pack_years_below_15": "Moins de 15 paquets-années.", + "pack_years_below_20": "Moins de 20 paquets-années.", + "smoked_years_below_25": "Moins de 25 ans de tabagisme.", + "missing_quit_date": "La date d'arrêt manque.", + "quit_over_10_years": "Arrêt il y a plus de 10 ans.", + "quit_over_15_years": "Arrêt il y a plus de 15 ans.", + "missing_sex": "Le sexe manque dans votre profil.", + "sex_not_male": "La règle s'applique aux hommes." + } + } } }, "environment": { diff --git a/messages/it.json b/messages/it.json index 2e29bb05c..5a0d6a2d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -384,7 +384,20 @@ "notes": "Note aggiuntive" }, "courseRange": "Ciclo dal {start} al {end}", - "courseSince": "Ciclo dal {start}" + "courseSince": "Ciclo dal {start}", + "lifePackYears": "Pacchetti-anno", + "lifeAuditC": "AUDIT-C (valutazione di screening)", + "lifeAuditCScore": "{score} su 12 il {date}.", + "lifeAuditCAbove": "Pari o sopra la soglia di {cutoff} ({source}).", + "lifeAuditCBelow": "Sotto la soglia di {cutoff} ({source}).", + "lifeAuditCNoCutoff": "Nessuna soglia specifica per sesso applicata.", + "lifeActivity": "Attività fisica", + "lifeActivityValue": "{minutes} minuti a settimana, dichiarati", + "lifeSitting": "Tempo seduto in un giorno lavorativo", + "lifeSittingValue": "{hours} ore, dichiarate", + "lifeWork": "Lavoro", + "lifeNightWork": "lavoro notturno", + "lifeExposures": "esposizioni: {list}" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Contesto dello stile di vita", - "cardDescription": "Registra i dati attuali che cambiano l'interpretazione delle tue misurazioni sanitarie.", "smokingLabel": "Abitudine al fumo", "alcoholLabel": "Consumo di alcol", "shiftLabel": "Orario di lavoro", "notRecorded": "Non registrato", - "unreadable": "Impossibile leggere il valore salvato", - "save": "Salva", - "savedToast": "Contesto dello stile di vita salvato", - "savePartialError": "Impossibile salvare {kinds}. Riprova.", - "saveKindError": "Non salvato", - "loadError": "Impossibile caricare il contesto dello stile di vita", "remove": "Rimuovi {kind}", - "removeShort": "Rimuovi", - "removeConfirmTitle": "Rimuovere {kind}?", - "removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.", - "removedToast": "{kind} rimosso dal contesto attuale dello stile di vita", - "removeError": "Impossibile rimuovere {kind}", - "historyTitle": "Valori precedenti", - "to": "a", "values": { "SMOKING_STATUS": { "NEVER": "Non ha mai fumato", @@ -11024,6 +11023,228 @@ "savedToast": "Informazioni di emergenza salvate", "saveError": "Impossibile salvare le informazioni di emergenza.", "loadError": "Impossibile caricare le informazioni di emergenza." + }, + "life": { + "cardTitle": "Circostanze di vita", + "cardDescription": "Fumo, alcol, attività, lavoro, abitudini e obiettivi, datati e tutti facoltativi.", + "loadError": "Impossibile caricare le tue circostanze di vita.", + "noAnswer": "Nessuna risposta", + "yes": "Sì", + "no": "No", + "validSince": "Valido dal", + "month": "Mese", + "year": "Anno", + "lastConfirmed": "Confermato l'ultima volta il {date}", + "notAnswered": "Non ancora compilato", + "unreadable": "Questa risposta non può essere letta su questo server.", + "save": "Salva sezione", + "savedToast": "Sezione salvata", + "saveError": "Impossibile salvare la sezione.", + "review": { + "question": "È ancora così? La tua ultima risposta ha più di un anno.", + "confirm": "È ancora così", + "confirmedToast": "Grazie, segnato come ancora valido", + "confirmError": "Impossibile salvare la conferma." + }, + "ai": { + "label": "Condividi questa sezione con l'IA", + "shiftLabel": "Condividi il lavoro a turni con l'IA", + "hint": "Se disattivato, il Coach e i riepiloghi non vedono mai queste risposte.", + "savedToast": "Condivisione con l'IA aggiornata", + "saveError": "Impossibile salvare l'impostazione dell'IA." + }, + "sections": { + "tobacco": { + "title": "Fumo e nicotina" + }, + "alcohol": { + "title": "Alcol" + }, + "activity": { + "title": "Attività e tempo seduto" + }, + "work": { + "title": "Lavoro e ritmo" + }, + "habits": { + "title": "Sonno, alimentazione e caffeina" + }, + "goals": { + "title": "Obiettivi" + } + }, + "fields": { + "smokingStatus": "Abitudine al fumo", + "products": "Prodotti usati", + "cigarettesPerDay": "Sigarette al giorno", + "startYear": "Anno di inizio", + "quitMonth": "Quando hai smesso", + "secondhandSmoke": "Fumo passivo regolare", + "moderateDaysPerWeek": "Giorni a settimana con attività moderata o intensa, come camminare a passo svelto", + "moderateMinutesPerDay": "Minuti in quei giorni, in media", + "sittingHoursWorkday": "Ore seduto in un giorno lavorativo", + "shiftSchedule": "Lavoro a turni", + "situation": "Situazione lavorativa", + "physicalNature": "Tipo di lavoro", + "nightWork": "Lavoro notturno", + "exposures": "Esposizioni al lavoro", + "freeWeekdays": "Giorni liberi della settimana", + "napRegularly": "Pisolino abituale", + "snoringReported": "Altri dicono che russi", + "dietPattern": "Alimentazione", + "dietSpecifics": "Particolarità", + "lateMealsPerWeek": "Pasti dopo le 21 a settimana", + "fruitVegPortionsPerDay": "Porzioni di frutta e verdura al giorno", + "caffeineDrinksPerDay": "Bevande con caffeina al giorno", + "latestCaffeine": "Ultima caffeina della giornata", + "goals": "Su cosa vuoi lavorare", + "goalsHint": "Al massimo tre." + }, + "values": { + "products": { + "cigarettes": "Sigarette", + "cigars_pipe": "Sigari o pipa", + "e_cigarette": "Sigaretta elettronica", + "heated_tobacco": "Tabacco riscaldato", + "snus_pouches": "Snus o bustine di nicotina" + }, + "situation": { + "none": "Nessun lavoro retribuito", + "indoor": "Soprattutto al chiuso", + "outdoor": "Soprattutto all'aperto", + "mixed": "Al chiuso e all'aperto" + }, + "physicalNature": { + "mostly_sitting": "Soprattutto seduto", + "standing_walking": "In piedi o camminando", + "heavy_physical": "Lavoro fisico pesante" + }, + "exposures": { + "heat": "Calore", + "cold": "Freddo", + "dust": "Polvere", + "fumes_solvents": "Fumi o solventi", + "noise": "Rumore" + }, + "dietPattern": { + "mixed": "Dieta mista", + "vegetarian": "Vegetariana", + "vegan": "Vegana", + "pescetarian": "Pescetariana", + "other": "Altro" + }, + "dietSpecifics": { + "intermittent_fasting": "Digiuno intermittente", + "low_carb": "Povera di carboidrati", + "prescribed_diet": "Dieta prescritta dal medico" + }, + "latestCaffeine": { + "before_12": "Prima delle 12", + "12_15": "Dalle 12 alle 15", + "15_18": "Dalle 15 alle 18", + "after_18": "Dopo le 18" + }, + "goals": { + "weight": "Peso", + "blood_pressure": "Pressione arteriosa", + "blood_sugar": "Glicemia", + "sleep": "Sonno", + "fitness": "Forma fisica", + "stress_recovery": "Stress e recupero", + "medication_routine": "Prendere i farmaci con regolarità", + "quit_smoking": "Smettere di fumare", + "less_alcohol": "Bere meno alcol" + }, + "weekdays": { + "1": "lun", + "2": "mar", + "3": "mer", + "4": "gio", + "5": "ven", + "6": "sab", + "7": "dom" + } + }, + "derived": { + "packYears": "{value} pacchetti-anno", + "packYearsMissing": "Per i pacchetti-anno mancano le sigarette al giorno o l'anno di inizio.", + "activityMinutes": "{value} minuti a settimana", + "meetsWho": "Raggiunge la raccomandazione dell'OMS di 150 minuti a settimana.", + "belowWho": "È sotto la raccomandazione dell'OMS di 150 minuti a settimana.", + "measuredSteps": "I tuoi passi misurati hanno la precedenza; questa risposta aggiunge contesto.", + "freeDaysDefault": "Senza risposta, sabato e domenica contano come giorni liberi." + }, + "auditC": { + "intro": "Tre domande sull'alcol tratte dall'AUDIT dell'OMS. Uno screening, non una diagnosi.", + "start": "Rispondi alle domande", + "again": "Rispondi di nuovo", + "offerDue": "Basta un'occhiata una volta all'anno.", + "notValidated": "Non c'è ancora una traduzione validata per la tua lingua, per questo le domande sono in inglese.", + "submit": "Salva le risposte", + "savedToast": "Risposte salvate", + "saveError": "Impossibile salvare le risposte.", + "result": "{score} punti su 12", + "above": "Pari o sopra la soglia di screening di {cutoff}.", + "below": "Sotto la soglia di screening di {cutoff}.", + "noCutoff": "Senza sesso nel profilo viene mostrata solo la somma.", + "source": "Fonte: {source}", + "history": "Voci precedenti", + "remove": "Rimuovi voce", + "removeConfirmTitle": "Rimuovere questa voce?", + "removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.", + "removedToast": "Voce rimossa", + "removeError": "Impossibile rimuovere la voce.", + "historyEntry": "{date}: {score} su 12" + }, + "takeOver": { + "conditionsButton": "Riporta nel diario delle condizioni", + "allergiesButton": "Riporta nelle allergie", + "conditionsTitle": "Aggiungi una malattia cronica", + "allergiesTitle": "Aggiungi un'allergia", + "description": "Il tuo testo libero resta com'è finché non lo cancelli tu.", + "freeTextLabel": "Il tuo testo libero", + "conditionLabel": "Malattia", + "onset": "Dal", + "save": "Aggiungi", + "savedToast": "Voce aggiunta", + "saveError": "Impossibile aggiungere la voce.", + "clearQuestion": "La voce è salvata. Cancellare ora il testo libero?", + "clear": "Cancella testo libero", + "keep": "Tienilo", + "clearedToast": "Testo libero cancellato", + "clearError": "Impossibile cancellare il testo libero." + }, + "screening": { + "cardTitle": "Indicazioni di screening", + "cardDescription": "Dalle tue circostanze di vita, con la regola e il paese da cui proviene.", + "noReminder": "È solo un'indicazione; se vuoi un promemoria, crealo tu.", + "source": "Fonte: {source}", + "titles": { + "lung_ldct": "TC a basso dosaggio per il tumore del polmone", + "aaa_ultrasound": "Ecografia dell'aorta addominale" + }, + "status": { + "suggested": "Potrebbe riguardarti", + "not_eligible": "Non ti riguarda", + "unknown": "Informazioni insufficienti" + }, + "reasons": { + "never_smoked": "Non hai mai fumato.", + "missing_birth_date": "Manca la tua data di nascita.", + "age_out_of_range": "La tua età è fuori dall'intervallo.", + "missing_smoking_status": "Manca la tua abitudine al fumo.", + "no_cigarettes": "La regola conta solo le sigarette.", + "missing_smoking_history": "Mancano le sigarette al giorno o l'anno di inizio.", + "pack_years_below_15": "Meno di 15 pacchetti-anno.", + "pack_years_below_20": "Meno di 20 pacchetti-anno.", + "smoked_years_below_25": "Meno di 25 anni di fumo.", + "missing_quit_date": "Manca la data in cui hai smesso.", + "quit_over_10_years": "Hai smesso più di 10 anni fa.", + "quit_over_15_years": "Hai smesso più di 15 anni fa.", + "missing_sex": "Nel profilo manca il sesso.", + "sex_not_male": "La regola vale per gli uomini." + } + } } }, "environment": { diff --git a/messages/ko.json b/messages/ko.json index 23d6ec87a..adaa582cd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -384,7 +384,20 @@ "immunizationsColDose": "차수", "immunizationsColLot": "로트번호", "courseRange": "복용 기간 {start}부터 {end}까지", - "courseSince": "복용 기간 {start}부터" + "courseSince": "복용 기간 {start}부터", + "lifePackYears": "갑년", + "lifeAuditC": "AUDIT-C (선별검사)", + "lifeAuditCScore": "{date}, 12점 중 {score}점.", + "lifeAuditCAbove": "기준점 {cutoff}점 이상 ({source}).", + "lifeAuditCBelow": "기준점 {cutoff}점 미만 ({source}).", + "lifeAuditCNoCutoff": "성별 기준점을 적용하지 않음.", + "lifeActivity": "신체 활동", + "lifeActivityValue": "주당 {minutes}분, 본인 응답", + "lifeSitting": "평일 앉아 있는 시간", + "lifeSittingValue": "{hours}시간, 본인 응답", + "lifeWork": "일", + "lifeNightWork": "야간 근무", + "lifeExposures": "노출: {list}" }, "errorBoundary": { "title": "문제가 생겼어요", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "생활 습관 정보", - "cardDescription": "측정값을 어떻게 해석해야 하는지에 영향을 주는 현재 상황을 기록해 주세요.", "smokingLabel": "흡연 상태", "alcoholLabel": "음주 패턴", "shiftLabel": "근무 일정", "notRecorded": "기록 없음", - "unreadable": "저장된 값을 읽지 못했어요", - "save": "저장", - "savedToast": "생활 습관 정보를 저장했어요", - "savePartialError": "저장 실패: {kinds}. 다시 시도해 주세요.", - "saveKindError": "저장 안 됨", - "loadError": "생활 습관 정보를 불러오지 못했어요", "remove": "삭제: {kind}", - "removeShort": "삭제", - "removeConfirmTitle": "{kind} 삭제할까요?", - "removeConfirmDescription": "현재 값이 '기록 없음'으로 바뀌어요. 날짜별 이력은 그대로 남아요.", - "removedToast": "현재 생활 습관 정보에서 삭제함: {kind}", - "removeError": "삭제하지 못함: {kind}", - "historyTitle": "이전 값", - "to": "~", "values": { "SMOKING_STATUS": { "NEVER": "피운 적 없음", @@ -11024,6 +11023,228 @@ "savedToast": "응급 정보를 저장했어요", "saveError": "응급 정보를 저장하지 못했어요.", "loadError": "응급 정보를 불러오지 못했어요." + }, + "life": { + "cardTitle": "생활 환경", + "cardDescription": "흡연, 음주, 활동, 일, 습관과 목표를 날짜와 함께, 모두 선택적으로 기록해요.", + "loadError": "생활 환경을 불러오지 못했어요.", + "noAnswer": "응답 안 함", + "yes": "예", + "no": "아니요", + "validSince": "적용 시작", + "month": "월", + "year": "연도", + "lastConfirmed": "{date}에 마지막으로 확인함", + "notAnswered": "아직 답하지 않음", + "unreadable": "이 서버에서는 이 응답을 읽을 수 없어요.", + "save": "이 항목 저장", + "savedToast": "항목을 저장했어요", + "saveError": "항목을 저장하지 못했어요.", + "review": { + "question": "아직 그대로인가요? 마지막 응답이 1년도 더 지났어요.", + "confirm": "그대로예요", + "confirmedToast": "고마워요. 여전히 맞다고 표시했어요", + "confirmError": "확인을 저장하지 못했어요." + }, + "ai": { + "label": "이 항목을 AI와 공유", + "shiftLabel": "교대 근무를 AI와 공유", + "hint": "끄면 코치와 브리핑이 이 응답을 전혀 보지 않아요.", + "savedToast": "AI 공유 설정을 바꿨어요", + "saveError": "AI 설정을 저장하지 못했어요." + }, + "sections": { + "tobacco": { + "title": "흡연과 니코틴" + }, + "alcohol": { + "title": "음주" + }, + "activity": { + "title": "활동과 앉아 있는 시간" + }, + "work": { + "title": "일과 생활 리듬" + }, + "habits": { + "title": "수면, 식사, 카페인" + }, + "goals": { + "title": "목표" + } + }, + "fields": { + "smokingStatus": "흡연 상태", + "products": "사용하는 제품", + "cigarettesPerDay": "하루 담배 개비 수", + "startYear": "흡연을 시작한 해", + "quitMonth": "금연한 시기", + "secondhandSmoke": "간접흡연에 자주 노출", + "moderateDaysPerWeek": "빠르게 걷기처럼 중간 강도 이상으로 움직이는 날, 주당", + "moderateMinutesPerDay": "그런 날의 평균 시간(분)", + "sittingHoursWorkday": "평일에 앉아 있는 시간", + "shiftSchedule": "교대 근무", + "situation": "근무 환경", + "physicalNature": "일의 종류", + "nightWork": "야간 근무", + "exposures": "일할 때의 노출", + "freeWeekdays": "쉬는 요일", + "napRegularly": "규칙적인 낮잠", + "snoringReported": "다른 사람이 코를 곤다고 함", + "dietPattern": "식사 방식", + "dietSpecifics": "특이 사항", + "lateMealsPerWeek": "주당 밤 9시 이후 식사 횟수", + "fruitVegPortionsPerDay": "하루 과일·채소 섭취량(회)", + "caffeineDrinksPerDay": "하루 카페인 음료 수", + "latestCaffeine": "하루 중 마지막 카페인", + "goals": "노력하고 싶은 부분", + "goalsHint": "최대 세 가지." + }, + "values": { + "products": { + "cigarettes": "궐련", + "cigars_pipe": "시가 또는 파이프", + "e_cigarette": "전자담배", + "heated_tobacco": "궐련형 전자담배", + "snus_pouches": "스누스 또는 니코틴 파우치" + }, + "situation": { + "none": "유급 노동 안 함", + "indoor": "주로 실내", + "outdoor": "주로 실외", + "mixed": "실내와 실외" + }, + "physicalNature": { + "mostly_sitting": "주로 앉아서", + "standing_walking": "서 있거나 걸으며", + "heavy_physical": "힘든 육체노동" + }, + "exposures": { + "heat": "더위", + "cold": "추위", + "dust": "먼지", + "fumes_solvents": "연기 또는 용제", + "noise": "소음" + }, + "dietPattern": { + "mixed": "일반식", + "vegetarian": "채식", + "vegan": "비건", + "pescetarian": "페스코 채식", + "other": "기타" + }, + "dietSpecifics": { + "intermittent_fasting": "간헐적 단식", + "low_carb": "저탄수화물", + "prescribed_diet": "의사가 처방한 식단" + }, + "latestCaffeine": { + "before_12": "정오 전", + "12_15": "정오부터 오후 3시", + "15_18": "오후 3시부터 6시", + "after_18": "오후 6시 이후" + }, + "goals": { + "weight": "체중", + "blood_pressure": "혈압", + "blood_sugar": "혈당", + "sleep": "수면", + "fitness": "체력", + "stress_recovery": "스트레스와 회복", + "medication_routine": "약을 규칙적으로 복용", + "quit_smoking": "금연", + "less_alcohol": "음주 줄이기" + }, + "weekdays": { + "1": "월", + "2": "화", + "3": "수", + "4": "목", + "5": "금", + "6": "토", + "7": "일" + } + }, + "derived": { + "packYears": "{value}갑년", + "packYearsMissing": "갑년을 계산하려면 하루 개비 수와 흡연 시작 연도가 필요해요.", + "activityMinutes": "주당 {value}분", + "meetsWho": "WHO 권장량인 주당 150분을 채워요.", + "belowWho": "WHO 권장량인 주당 150분보다 적어요.", + "measuredSteps": "측정된 걸음 수가 우선이고, 이 응답은 맥락을 더해요.", + "freeDaysDefault": "응답이 없으면 토요일과 일요일을 쉬는 날로 봐요." + }, + "auditC": { + "intro": "WHO AUDIT의 음주 관련 세 문항이에요. 선별검사이며 진단이 아니에요.", + "start": "문항에 답하기", + "again": "다시 답하기", + "offerDue": "1년에 한 번 확인하면 충분해요.", + "notValidated": "사용하는 언어로 검증된 번역이 아직 없어 문항을 영어로 보여 드려요.", + "submit": "답변 저장", + "savedToast": "답변을 저장했어요", + "saveError": "답변을 저장하지 못했어요.", + "result": "12점 중 {score}점", + "above": "선별 기준점 {cutoff}점 이상이에요.", + "below": "선별 기준점 {cutoff}점 미만이에요.", + "noCutoff": "프로필에 성별이 없어 합계만 보여 드려요.", + "source": "출처: {source}", + "history": "이전 기록", + "remove": "기록 삭제", + "removeConfirmTitle": "이 기록을 삭제할까요?", + "removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.", + "removedToast": "기록을 삭제했어요", + "removeError": "기록을 삭제하지 못했어요.", + "historyEntry": "{date}: 12점 중 {score}점" + }, + "takeOver": { + "conditionsButton": "상태 일지로 옮기기", + "allergiesButton": "알레르기로 옮기기", + "conditionsTitle": "만성 질환 추가", + "allergiesTitle": "알레르기 추가", + "description": "직접 지우기 전까지 자유 입력 내용은 그대로 남아요.", + "freeTextLabel": "자유 입력 내용", + "conditionLabel": "질환", + "onset": "시작", + "save": "추가", + "savedToast": "항목을 추가했어요", + "saveError": "항목을 추가하지 못했어요.", + "clearQuestion": "항목을 저장했어요. 지금 자유 입력 내용을 지울까요?", + "clear": "자유 입력 지우기", + "keep": "그대로 두기", + "clearedToast": "자유 입력 내용을 지웠어요", + "clearError": "자유 입력 내용을 지우지 못했어요." + }, + "screening": { + "cardTitle": "검진 안내", + "cardDescription": "생활 환경에서 나온 안내로, 기준과 그 기준을 만든 나라를 함께 보여 줘요.", + "noReminder": "안내일 뿐이에요. 알림이 필요하면 직접 만들어 주세요.", + "source": "출처: {source}", + "titles": { + "lung_ldct": "폐암 저선량 CT", + "aaa_ultrasound": "복부 대동맥 초음파" + }, + "status": { + "suggested": "해당될 수 있어요", + "not_eligible": "해당되지 않아요", + "unknown": "정보가 부족해요" + }, + "reasons": { + "never_smoked": "담배를 피운 적이 없어요.", + "missing_birth_date": "생년월일이 없어요.", + "age_out_of_range": "나이가 기준 범위 밖이에요.", + "missing_smoking_status": "흡연 상태가 없어요.", + "no_cigarettes": "이 기준은 궐련만 세어요.", + "missing_smoking_history": "하루 개비 수나 흡연 시작 연도가 없어요.", + "pack_years_below_15": "15갑년 미만이에요.", + "pack_years_below_20": "20갑년 미만이에요.", + "smoked_years_below_25": "흡연 기간이 25년 미만이에요.", + "missing_quit_date": "금연 시기가 없어요.", + "quit_over_10_years": "금연한 지 10년이 넘었어요.", + "quit_over_15_years": "금연한 지 15년이 넘었어요.", + "missing_sex": "프로필에 성별이 없어요.", + "sex_not_male": "이 기준은 남성에게 적용돼요." + } + } } }, "environment": { diff --git a/messages/pl.json b/messages/pl.json index 2f1669774..6440e85b0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -384,7 +384,20 @@ "notes": "Dodatkowe uwagi" }, "courseRange": "Kuracja od {start} do {end}", - "courseSince": "Kuracja od {start}" + "courseSince": "Kuracja od {start}", + "lifePackYears": "Paczkolata", + "lifeAuditC": "AUDIT-C (badanie przesiewowe)", + "lifeAuditCScore": "{score} z 12 dnia {date}.", + "lifeAuditCAbove": "Na lub powyżej progu {cutoff} ({source}).", + "lifeAuditCBelow": "Poniżej progu {cutoff} ({source}).", + "lifeAuditCNoCutoff": "Bez progu zależnego od płci.", + "lifeActivity": "Aktywność fizyczna", + "lifeActivityValue": "{minutes} minut w tygodniu, według deklaracji", + "lifeSitting": "Siedzenie w dzień roboczy", + "lifeSittingValue": "{hours} godz., według deklaracji", + "lifeWork": "Praca", + "lifeNightWork": "praca w nocy", + "lifeExposures": "narażenia: {list}" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -10826,25 +10839,11 @@ }, "profileFacts": { "cardTitle": "Kontekst stylu życia", - "cardDescription": "Zapisz aktualne informacje, które zmieniają interpretację pomiarów zdrowotnych.", "smokingLabel": "Palenie tytoniu", "alcoholLabel": "Wzorzec spożycia alkoholu", "shiftLabel": "Harmonogram pracy", "notRecorded": "Brak danych", - "unreadable": "Nie udało się odczytać zapisanej wartości", - "save": "Zapisz", - "savedToast": "Zapisano kontekst stylu życia", - "savePartialError": "Nie udało się zapisać {kinds}. Spróbuj ponownie.", - "saveKindError": "Nie zapisano", - "loadError": "Nie udało się wczytać kontekstu stylu życia", "remove": "Usuń: {kind}", - "removeShort": "Usuń", - "removeConfirmTitle": "Usunąć: {kind}?", - "removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.", - "removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia", - "removeError": "Nie udało się usunąć: {kind}", - "historyTitle": "Poprzednie wartości", - "to": "do", "values": { "SMOKING_STATUS": { "NEVER": "Nigdy nie pali", @@ -11024,6 +11023,228 @@ "savedToast": "Informacje na wypadek nagły zapisane", "saveError": "Nie udało się zapisać informacji na wypadek nagły.", "loadError": "Nie udało się wczytać informacji na wypadek nagły." + }, + "life": { + "cardTitle": "Warunki życia", + "cardDescription": "Palenie, alkohol, aktywność, praca, nawyki i cele, z datą i zawsze dobrowolnie.", + "loadError": "Nie udało się wczytać warunków życia.", + "noAnswer": "Brak odpowiedzi", + "yes": "Tak", + "no": "Nie", + "validSince": "Obowiązuje od", + "month": "Miesiąc", + "year": "Rok", + "lastConfirmed": "Ostatnio potwierdzono {date}", + "notAnswered": "Jeszcze bez odpowiedzi", + "unreadable": "Tej odpowiedzi nie da się odczytać na tym serwerze.", + "save": "Zapisz sekcję", + "savedToast": "Sekcja zapisana", + "saveError": "Nie udało się zapisać sekcji.", + "review": { + "question": "Czy to nadal aktualne? Twoja ostatnia odpowiedź ma ponad rok.", + "confirm": "Nadal aktualne", + "confirmedToast": "Dziękujemy, oznaczono jako aktualne", + "confirmError": "Nie udało się zapisać potwierdzenia." + }, + "ai": { + "label": "Udostępnij tę sekcję AI", + "shiftLabel": "Udostępnij pracę zmianową AI", + "hint": "Po wyłączeniu Coach i podsumowania nigdy nie widzą tych odpowiedzi.", + "savedToast": "Udostępnianie AI zaktualizowane", + "saveError": "Nie udało się zapisać ustawienia AI." + }, + "sections": { + "tobacco": { + "title": "Palenie i nikotyna" + }, + "alcohol": { + "title": "Alkohol" + }, + "activity": { + "title": "Aktywność i siedzenie" + }, + "work": { + "title": "Praca i rytm dnia" + }, + "habits": { + "title": "Sen, odżywianie i kofeina" + }, + "goals": { + "title": "Cele" + } + }, + "fields": { + "smokingStatus": "Palenie tytoniu", + "products": "Używane produkty", + "cigarettesPerDay": "Papierosy dziennie", + "startYear": "Rok rozpoczęcia", + "quitMonth": "Kiedy rzucono", + "secondhandSmoke": "Regularne bierne palenie", + "moderateDaysPerWeek": "Dni w tygodniu z umiarkowaną lub intensywną aktywnością, np. szybkim marszem", + "moderateMinutesPerDay": "Minuty w te dni, średnio", + "sittingHoursWorkday": "Godziny siedzenia w dzień roboczy", + "shiftSchedule": "Praca zmianowa", + "situation": "Sytuacja zawodowa", + "physicalNature": "Rodzaj pracy", + "nightWork": "Praca w nocy", + "exposures": "Narażenia w pracy", + "freeWeekdays": "Wolne dni tygodnia", + "napRegularly": "Regularna drzemka", + "snoringReported": "Inni mówią, że chrapiesz", + "dietPattern": "Sposób odżywiania", + "dietSpecifics": "Szczegóły", + "lateMealsPerWeek": "Posiłki po 21:00 w tygodniu", + "fruitVegPortionsPerDay": "Porcje owoców i warzyw dziennie", + "caffeineDrinksPerDay": "Napoje z kofeiną dziennie", + "latestCaffeine": "Najpóźniejsza kofeina w ciągu dnia", + "goals": "Nad czym chcesz pracować", + "goalsHint": "Najwyżej trzy." + }, + "values": { + "products": { + "cigarettes": "Papierosy", + "cigars_pipe": "Cygara lub fajka", + "e_cigarette": "E-papieros", + "heated_tobacco": "Podgrzewany tytoń", + "snus_pouches": "Snus lub saszetki nikotynowe" + }, + "situation": { + "none": "Bez pracy zarobkowej", + "indoor": "Głównie w pomieszczeniach", + "outdoor": "Głównie na zewnątrz", + "mixed": "W środku i na zewnątrz" + }, + "physicalNature": { + "mostly_sitting": "Głównie siedząca", + "standing_walking": "Stojąca lub w ruchu", + "heavy_physical": "Ciężka praca fizyczna" + }, + "exposures": { + "heat": "Upał", + "cold": "Zimno", + "dust": "Pył", + "fumes_solvents": "Opary lub rozpuszczalniki", + "noise": "Hałas" + }, + "dietPattern": { + "mixed": "Dieta mieszana", + "vegetarian": "Wegetariańska", + "vegan": "Wegańska", + "pescetarian": "Peskatariańska", + "other": "Inna" + }, + "dietSpecifics": { + "intermittent_fasting": "Post przerywany", + "low_carb": "Niskowęglowodanowa", + "prescribed_diet": "Dieta zalecona przez lekarza" + }, + "latestCaffeine": { + "before_12": "Przed 12:00", + "12_15": "12:00 do 15:00", + "15_18": "15:00 do 18:00", + "after_18": "Po 18:00" + }, + "goals": { + "weight": "Waga", + "blood_pressure": "Ciśnienie krwi", + "blood_sugar": "Cukier we krwi", + "sleep": "Sen", + "fitness": "Kondycja", + "stress_recovery": "Stres i regeneracja", + "medication_routine": "Regularne przyjmowanie leków", + "quit_smoking": "Rzucenie palenia", + "less_alcohol": "Mniej alkoholu" + }, + "weekdays": { + "1": "pon.", + "2": "wt.", + "3": "śr.", + "4": "czw.", + "5": "pt.", + "6": "sob.", + "7": "niedz." + } + }, + "derived": { + "packYears": "{value} paczkolat", + "packYearsMissing": "Do paczkolat brakuje liczby papierosów dziennie albo roku rozpoczęcia.", + "activityMinutes": "{value} minut w tygodniu", + "meetsWho": "To spełnia zalecenie WHO 150 minut tygodniowo.", + "belowWho": "To poniżej zalecenia WHO 150 minut tygodniowo.", + "measuredSteps": "Zmierzone kroki mają pierwszeństwo, ta odpowiedź dodaje kontekst.", + "freeDaysDefault": "Bez odpowiedzi sobota i niedziela liczą się jako dni wolne." + }, + "auditC": { + "intro": "Trzy pytania o alkohol z testu AUDIT WHO. Badanie przesiewowe, nie diagnoza.", + "start": "Odpowiedz na pytania", + "again": "Odpowiedz ponownie", + "offerDue": "Wystarczy zajrzeć raz w roku.", + "submit": "Zapisz odpowiedzi", + "notValidated": "Dla Twojego języka nie ma jeszcze zwalidowanego tłumaczenia, dlatego pytania są po angielsku.", + "savedToast": "Odpowiedzi zapisane", + "saveError": "Nie udało się zapisać odpowiedzi.", + "result": "{score} z 12 punktów", + "above": "Na lub powyżej progu przesiewowego {cutoff}.", + "below": "Poniżej progu przesiewowego {cutoff}.", + "noCutoff": "Bez płci w profilu pokazujemy tylko sumę.", + "source": "Źródło: {source}", + "history": "Wcześniejsze wpisy", + "remove": "Usuń wpis", + "removeConfirmTitle": "Usunąć ten wpis?", + "removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.", + "removedToast": "Wpis usunięty", + "removeError": "Nie udało się usunąć wpisu.", + "historyEntry": "{date}: {score} z 12" + }, + "takeOver": { + "conditionsButton": "Przenieś do dziennika stanów", + "allergiesButton": "Przenieś do alergii", + "conditionsTitle": "Dodaj chorobę przewlekłą", + "allergiesTitle": "Dodaj alergię", + "description": "Twój tekst pozostaje bez zmian, dopóki sam go nie wyczyścisz.", + "freeTextLabel": "Twój tekst", + "conditionLabel": "Choroba", + "onset": "Od", + "save": "Dodaj", + "savedToast": "Wpis dodany", + "saveError": "Nie udało się dodać wpisu.", + "clearQuestion": "Wpis zapisany. Wyczyścić teraz tekst?", + "clear": "Wyczyść tekst", + "keep": "Zostaw", + "clearedToast": "Tekst wyczyszczony", + "clearError": "Nie udało się wyczyścić tekstu." + }, + "screening": { + "cardTitle": "Wskazówki dotyczące badań przesiewowych", + "cardDescription": "Na podstawie warunków życia, z regułą i krajem, z którego pochodzi.", + "noReminder": "To tylko wskazówka; przypomnienie ustaw sam, jeśli chcesz.", + "source": "Źródło: {source}", + "titles": { + "lung_ldct": "Niskodawkowa TK w kierunku raka płuca", + "aaa_ultrasound": "USG aorty brzusznej" + }, + "status": { + "suggested": "Może Cię dotyczyć", + "not_eligible": "Nie dotyczy Cię", + "unknown": "Za mało informacji" + }, + "reasons": { + "never_smoked": "Nigdy nie paliłeś.", + "missing_birth_date": "Brakuje daty urodzenia.", + "age_out_of_range": "Wiek poza zakresem.", + "missing_smoking_status": "Brakuje informacji o paleniu.", + "no_cigarettes": "Reguła liczy tylko papierosy.", + "missing_smoking_history": "Brakuje liczby papierosów dziennie lub roku rozpoczęcia.", + "pack_years_below_15": "Mniej niż 15 paczkolat.", + "pack_years_below_20": "Mniej niż 20 paczkolat.", + "smoked_years_below_25": "Mniej niż 25 lat palenia.", + "missing_quit_date": "Brakuje daty rzucenia.", + "quit_over_10_years": "Rzucenie ponad 10 lat temu.", + "quit_over_15_years": "Rzucenie ponad 15 lat temu.", + "missing_sex": "W profilu brakuje płci.", + "sex_not_male": "Reguła dotyczy mężczyzn." + } + } } }, "environment": { diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index f6f0b5ec8..611783b68 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -47,7 +47,6 @@ const REQUIRED_KEYS = [ "insights.healthScore.versionAndNoise", "insights.healthScore.methodVersion", "insights.healthScore.source", - "records.profileFacts.save", "records.allergies.add", "records.allergies.addFirst", "records.family.add", @@ -62,6 +61,9 @@ const OBSOLETE_KEYS = [ "insights.healthScore.composition", "documents.detail.summary.regenerate", "records.profileFacts.correct", + // v1.43 — the profile-facts card became the life-circumstances card, whose + // sections save on their own. + "records.profileFacts.save", "settings.nightscoutPrivateHost", "settings.nightscoutPrivateHostHelp", ] as const; diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 5a0a78d98..55a3d77c3 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -53,6 +53,7 @@ * map the shared snapshot builder uses (`disabledSummaryTypes`); the filter * runs OUTSIDE the cache because a module toggle does not evict it. */ +import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -717,6 +718,8 @@ async function buildDashboardSummary( sleepPriorityJson, ), sleepNeedMinutes(sleepProfile.ageYears), + "COMPUTED", + await loadFreeWeekdaySpans(userId), ); const metrics: MetricCard[] = []; diff --git a/src/components/records/__tests__/health-profile-facts-manager.test.tsx b/src/components/records/__tests__/health-profile-facts-manager.test.tsx deleted file mode 100644 index e55a54b18..000000000 --- a/src/components/records/__tests__/health-profile-facts-manager.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { HealthProfileFactsManager } from "../health-profile-facts-manager"; - -const { - MockApiError, - apiDelete, - apiPatch, - apiPost, - toastError, - toastWritten, - confirmButtons, - renderedButtons, - selects, - removedAt, -} = vi.hoisted(() => { - const removedAt = "2026-07-28T12:00:00.000Z"; - class MockApiError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } - } - return { - MockApiError, - apiDelete: vi.fn(async () => ({ - id: "fact-1", - kind: "SMOKING_STATUS" as const, - removedAt, - })), - apiPatch: vi.fn(), - apiPost: vi.fn(), - toastError: vi.fn(), - toastWritten: vi.fn(), - confirmButtons: [] as Record[], - renderedButtons: [] as Record[], - selects: [] as Record[], - removedAt, - }; -}); - -vi.mock("@/lib/api/api-fetch", () => ({ - ApiError: MockApiError, - apiDelete, - apiGet: vi.fn(), - apiPatch, - apiPost, -})); -vi.mock("sonner", () => ({ toast: { error: toastError } })); -vi.mock("@/components/outcome/outcome-toast", () => ({ - toastWrittenOutcome: toastWritten, -})); -vi.mock("@/components/ui/button", () => ({ - Button: (props: Record) => { - renderedButtons.push(props); - return - ); - }, - ConfirmDialog: () => null, -})); - -const fact = { - id: "fact-1", - kind: "SMOKING_STATUS" as const, - value: "FORMER" as const, - unreadable: false, - validFrom: "2026-07-28T10:00:00.000Z", - validUntil: null, - provenance: "USER_REPORTED" as const, - supersededByRevisionId: null, - createdAt: "2026-07-28T10:00:00.000Z", -}; - -function render() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }); - client.setQueryData(queryKeys.healthProfileFacts(), { - current: { - SMOKING_STATUS: fact, - ALCOHOL_PATTERN: null, - SHIFT_SCHEDULE: null, - }, - history: [fact], - }); - const html = renderToStaticMarkup( - - - - - , - ); - return { client, html }; -} - -// The one primary action: default variant, a button-type, its own onClick. -function saveButton() { - return renderedButtons.find( - (props) => - props.type === "button" && - props.variant === undefined && - typeof props.onClick === "function", - ); -} - -function editDraft(index: number, value: string) { - (selects[index].onValueChange as (next: string) => void)(value); -} - -beforeEach(() => { - confirmButtons.length = 0; - renderedButtons.length = 0; - selects.length = 0; - apiDelete.mockClear(); - apiPatch.mockReset(); - apiPost.mockReset(); - toastError.mockClear(); - toastWritten.mockClear(); -}); - -describe("health-profile fact save + removal controls", () => { - it("renders exactly one Save for the whole lifestyle-context tile", () => { - render(); - - const actionButtons = renderedButtons.filter( - (props) => - props.type === "button" && - props.variant === undefined && - typeof props.onClick === "function", - ); - const labels = actionButtons.map((props) => { - const children = Array.isArray(props.children) - ? props.children - : [props.children]; - return children.filter((child) => typeof child === "string").join(""); - }); - - expect(labels).toEqual(["Save"]); - }); - - it("renders a confirmed removal action only for each recorded current fact", () => { - const { html } = render(); - - expect(confirmButtons).toHaveLength(1); - // The visible label is short — three entries side by side each carrying - // their own field name read as one run of words — but the ACCESSIBLE name - // still says which entry it removes, and so does the dialog it opens. - expect(confirmButtons[0]).toMatchObject({ - slot: "health-profile-fact-remove-SMOKING_STATUS", - label: "Remove", - ariaLabel: "Remove Smoking status", - confirmLabel: "Remove Smoking status", - title: "Remove Smoking status?", - body: "The current value will become not recorded. Its dated history will be retained.", - }); - expect(html).toContain("Remove"); - expect(apiDelete).not.toHaveBeenCalled(); - }); - - it("uses the current revision id as the concurrency token after confirmation", async () => { - render(); - - (confirmButtons[0].onConfirm as () => void)(); - - await vi.waitFor(() => - expect(apiDelete).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1"), - ); - }); - - it("optimistically resolves the current fact as absent while retaining closed history", async () => { - const { client } = render(); - - (confirmButtons[0].onConfirm as () => void)(); - await vi.waitFor(() => { - const data = client.getQueryData<{ - current: Record; - history: Array<{ id: string; validUntil: string | null }>; - }>(queryKeys.healthProfileFacts()); - expect(data?.current.SMOKING_STATUS).toBeNull(); - expect(data?.history).toEqual([ - expect.objectContaining({ id: "fact-1", validUntil: removedAt }), - ]); - }); - }); - - it("invalidates advisor data after a successful save", async () => { - apiPatch.mockResolvedValueOnce({ ...fact, value: "NEVER" }); - const { client } = render(); - const invalidate = vi.spyOn(client, "invalidateQueries"); - - // Revise the recorded smoking status, then hit the one Save. - editDraft(0, "NEVER"); - (saveButton()!.onClick as () => void)(); - - await vi.waitFor(() => - expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", { - value: "NEVER", - }), - ); - expect(invalidate).toHaveBeenCalledWith({ - queryKey: queryKeys.insightsAdvisor(), - }); - expect(toastWritten).toHaveBeenCalledWith( - "success", - "Lifestyle context saved", - ); - }); - - it("invalidates the facts read when a save finds a stale 404 target", async () => { - apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404)); - const { client } = render(); - const invalidate = vi.spyOn(client, "invalidateQueries"); - - editDraft(0, "NEVER"); - (saveButton()!.onClick as () => void)(); - - await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce()); - expect(invalidate).toHaveBeenCalledWith({ - queryKey: queryKeys.healthProfileFacts(), - }); - // A stale target is a failure, never a green toast. - expect(toastWritten).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalled(); - }); - - it("invalidates advisor data after a successful removal", async () => { - const { client } = render(); - const invalidate = vi.spyOn(client, "invalidateQueries"); - - (confirmButtons[0].onConfirm as () => void)(); - - await vi.waitFor(() => - expect(invalidate).toHaveBeenCalledWith({ - queryKey: queryKeys.insightsAdvisor(), - }), - ); - }); - - it("invalidates the facts read when removal finds a stale 404 target", async () => { - apiDelete.mockRejectedValueOnce(new MockApiError("Not found", 404)); - const { client } = render(); - const invalidate = vi.spyOn(client, "invalidateQueries"); - - (confirmButtons[0].onConfirm as () => void)(); - - await vi.waitFor(() => expect(apiDelete).toHaveBeenCalledOnce()); - expect(invalidate).toHaveBeenCalledWith({ - queryKey: queryKeys.healthProfileFacts(), - }); - }); - - it("writes every dirty kind and keeps a conflicted kind while the other persists", async () => { - // Two dirty kinds: SMOKING revises an existing fact (PATCH), ALCOHOL is a - // first value (POST). Force the PATCH to 409 and prove the POST still lands, - // the failure is surfaced per-kind, and the conflicted draft survives. - apiPatch.mockRejectedValue(new MockApiError("Stale", 409)); - apiPost.mockResolvedValue({ - id: "fact-2", - kind: "ALCOHOL_PATTERN", - value: "WEEKLY", - }); - render(); - - editDraft(0, "NEVER"); // SMOKING_STATUS, differs from FORMER - editDraft(1, "WEEKLY"); // ALCOHOL_PATTERN, first value - (saveButton()!.onClick as () => void)(); - - // Both writes reach the real endpoints in one Save. - await vi.waitFor(() => expect(toastError).toHaveBeenCalled()); - expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", { - value: "NEVER", - }); - expect(apiPost).toHaveBeenCalledWith("/api/anamnesis/facts", { - kind: "ALCOHOL_PATTERN", - value: "WEEKLY", - }); - - // The partial failure is not hidden behind a green toast: the error names - // the kind that did not land, and no success toast fired. - expect(toastError).toHaveBeenCalledWith( - expect.stringContaining("Smoking status"), - ); - expect(toastWritten).not.toHaveBeenCalled(); - - // The conflicted draft survives while the persisted one is consumed: a - // second Save re-attempts only SMOKING and never re-POSTs ALCOHOL. - apiPatch.mockClear(); - apiPost.mockClear(); - (saveButton()!.onClick as () => void)(); - - await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce()); - expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", { - value: "NEVER", - }); - expect(apiPost).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 176e264bf..ba657df58 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -43,6 +43,18 @@ const SECTION_LABEL_KEY: Record = { SHIFT_SCHEDULE: "records.aiInclusion.sectionShiftSchedule", }; +/** + * v1.43 (#614) — smoking, alcohol and shift work are switched beside their + * answers in the life-circumstances card; listing them here as well would put + * two switches for one setting on one page. They still travel in the PUT + * below with their saved value, so this card never changes them. + */ +const LIFE_CARD_SECTIONS: ReadonlySet = new Set([ + "SMOKING_STATUS", + "ALCOHOL_PATTERN", + "SHIFT_SCHEDULE", +]); + export function AiProfileInclusionManager() { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -83,6 +95,9 @@ export function AiProfileInclusionManager() { queryClient.invalidateQueries({ queryKey: queryKeys.insightsAdvisor(), }); + queryClient.invalidateQueries({ + queryKey: queryKeys.lifeCircumstances(), + }); }, onError: (error) => { if (isConflict(error)) { @@ -106,7 +121,9 @@ export function AiProfileInclusionManager() { return (
- {HEALTH_PROFILE_AI_SECTIONS.map((section) => { + {HEALTH_PROFILE_AI_SECTIONS.filter( + (section) => !LIFE_CARD_SECTIONS.has(section), + ).map((section) => { const checked = value.includes(section); return (
{leadingAction} + {/* v1.43 (#614) — move the free text into a structured entry by hand. */} + + + + submit.mutate(answers as number[])} + data-slot="audit-c-submit" + > + {submit.isPending && ( + + )} + {t("records.life.auditC.submit")} + + } + > +
+ {!instrument.validatedLocale ? ( +

+ {t("records.life.auditC.notValidated")} +

+ ) : null} +

+ {instrument.standardDrink} +

+ {instrument.items.map((item, index) => ( +
+ + {index + 1}. {item.stem} + +
+ {item.options.map((option) => ( + + ))} +
+
+ ))} +

+ {instrument.attribution} +

+
+
+
+ ); +} diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 9045d4c90..aa25fa41a 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -29,6 +29,7 @@ import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Label } from "@/components/ui/label"; +import { FreeTextTakeOver } from "@/components/records/free-text-take-over"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -140,6 +141,8 @@ export function ConditionsManager() { t("settings.ai.aboutMe.focusPlaceholder"), )} + {/* v1.43 (#614) — move the free text into the condition journal by hand. */} + + (next ? setOpen(true) : close())} + title={title} + description={t("records.life.takeOver.description")} + footer={ + saved ? ( + <> + + + + ) : field === "conditions" ? ( + + ) : ( +
+ ) + } + > +
+
+

+ {t("records.life.takeOver.freeTextLabel")} +

+

+ {text} +

+
+ {saved ? ( +

+ {t("records.life.takeOver.clearQuestion")} +

+ ) : field === "conditions" ? ( + <> +
+ + setLabel(event.target.value)} + /> +
+
+ + +
+ + ) : ( + + )} +
+ + + ); +} diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx deleted file mode 100644 index 0d405ad34..000000000 --- a/src/components/records/health-profile-facts-manager.tsx +++ /dev/null @@ -1,426 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2 } from "lucide-react"; -import { showErrorToast } from "@/lib/errors/error-toast"; - -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { ConfirmButton } from "@/components/ui/confirm-button"; -import { Label } from "@/components/ui/label"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - ApiError, - apiDelete, - apiGet, - apiPatch, - apiPost, -} from "@/lib/api/api-fetch"; -import { isConflict } from "@/lib/api/optimistic-token"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { - ALCOHOL_PATTERN_VALUES, - HEALTH_PROFILE_FACT_KINDS, - SHIFT_SCHEDULE_VALUES, - SMOKING_STATUS_VALUES, - type HealthProfileFactKind, - type HealthProfileFactValue, -} from "@/lib/validations/health-profile-facts"; - -interface FactDto { - id: string; - kind: HealthProfileFactKind; - value: HealthProfileFactValue | null; - unreadable: boolean; - validFrom: string; - validUntil: string | null; - provenance: "USER_REPORTED" | "USER_CORRECTION"; - supersededByRevisionId: string | null; - createdAt: string; -} - -interface FactsData { - current: Record; - history: FactDto[]; -} - -interface RemovedFactDto { - id: string; - kind: HealthProfileFactKind; - removedAt: string; -} - -const VALUES: Record = { - SMOKING_STATUS: SMOKING_STATUS_VALUES, - ALCOHOL_PATTERN: ALCOHOL_PATTERN_VALUES, - SHIFT_SCHEDULE: SHIFT_SCHEDULE_VALUES, -}; - -function isStaleFactTarget(error: unknown): boolean { - return ( - isConflict(error) || (error instanceof ApiError && error.status === 404) - ); -} - -const KIND_LABEL: Record = { - SMOKING_STATUS: "records.profileFacts.smokingLabel", - ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel", - SHIFT_SCHEDULE: "records.profileFacts.shiftLabel", -}; - -export function HealthProfileFactsManager() { - const { t } = useTranslations(); - const fmt = useFormatters(); - const queryClient = useQueryClient(); - const [drafts, setDrafts] = useState< - Partial> - >({}); - // Kinds whose last save attempt failed. A per-kind flag rather than a single - // banner, so a partial failure names which entry the user still has to redo - // instead of hiding it behind one green toast. - const [failedKinds, setFailedKinds] = useState< - Partial> - >({}); - // Live copy of the draft set. State drives the controlled Selects; this ref - // is what the one footer Save reads at click time, so a multi-field save - // always writes the latest selections and never a value a render lagged on. - const draftsRef = useRef>>({}); - - const editDraft = (kind: HealthProfileFactKind, value: string) => { - draftsRef.current = { ...draftsRef.current, [kind]: value }; - setDrafts(draftsRef.current); - // Editing a kind clears its stale failure marker; the next Save decides its - // outcome afresh. - setFailedKinds((old) => (old[kind] ? { ...old, [kind]: undefined } : old)); - }; - - const query = useQuery({ - queryKey: queryKeys.healthProfileFacts(), - queryFn: () => apiGet("/api/anamnesis/facts"), - }); - - const save = useMutation({ - mutationKey: queryKeys.healthProfileFacts(), - // One Save for the whole tile: iterate only the dirty kinds, fire the right - // bitemporal call per kind (PATCH a revision, POST a first value), and settle - // them together so a single 409 cannot take the healthy writes down with it. - mutationFn: async ( - inputs: Array<{ - kind: HealthProfileFactKind; - value: string; - current: FactDto | null; - }>, - ) => { - const settled = await Promise.allSettled( - inputs.map((input) => - input.current - ? apiPatch(`/api/anamnesis/facts/${input.current.id}`, { - value: input.value, - }) - : apiPost("/api/anamnesis/facts", { - kind: input.kind, - value: input.value, - }), - ), - ); - return inputs.map((input, index) => ({ - kind: input.kind, - outcome: settled[index], - })); - }, - onSuccess: (results) => { - const succeeded = results.filter((r) => r.outcome.status === "fulfilled"); - const failed = results.filter((r) => r.outcome.status === "rejected"); - - // Clear the drafts and the failure flags only for the kinds that landed. - // A kind that 409'd keeps its draft on screen so the user can retry it. - const remaining = { ...draftsRef.current }; - for (const { kind } of succeeded) delete remaining[kind]; - draftsRef.current = remaining; - setDrafts(remaining); - setFailedKinds((current) => { - const next = { ...current }; - for (const { kind } of succeeded) next[kind] = undefined; - for (const { kind } of failed) next[kind] = true; - return next; - }); - - if (succeeded.length > 0) { - queryClient.invalidateQueries({ - queryKey: queryKeys.healthProfileFacts(), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsAdvisor(), - }); - } else if ( - failed.some((r) => - isStaleFactTarget((r.outcome as PromiseRejectedResult).reason), - ) - ) { - // Nothing persisted, but a stale target means our view is behind the - // server; refetch so the next attempt revises the current revision. - queryClient.invalidateQueries({ - queryKey: queryKeys.healthProfileFacts(), - }); - } - - if (failed.length === 0) { - toastWrittenOutcome("success", t("records.profileFacts.savedToast")); - } else { - showErrorToast( - t("records.profileFacts.savePartialError", { - kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "), - }), - ); - } - }, - }); - - const remove = useMutation({ - mutationKey: queryKeys.healthProfileFacts(), - mutationFn: (current: FactDto) => - apiDelete(`/api/anamnesis/facts/${current.id}`), - onSuccess: (removed) => { - setDrafts((current) => ({ ...current, [removed.kind]: undefined })); - queryClient.setQueryData( - queryKeys.healthProfileFacts(), - (facts) => - facts - ? { - current: { - ...facts.current, - [removed.kind]: null, - }, - history: facts.history.map((revision) => - revision.id === removed.id - ? { ...revision, validUntil: removed.removedAt } - : revision, - ), - } - : facts, - ); - toastWrittenOutcome( - "success", - t("records.profileFacts.removedToast", { - kind: t(KIND_LABEL[removed.kind]), - }), - ); - queryClient.invalidateQueries({ - queryKey: queryKeys.healthProfileFacts(), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsAdvisor(), - }); - }, - onError: (error, current) => { - if (isStaleFactTarget(error)) { - queryClient.invalidateQueries({ - queryKey: queryKeys.healthProfileFacts(), - }); - } - showErrorToast( - t("records.profileFacts.removeError", { - kind: t(KIND_LABEL[current.kind]), - }), - { cause: error }, - ); - }, - }); - - if (query.isError) { - return ( - void query.refetch()} - /> - ); - } - - const disabled = query.isLoading || save.isPending || remove.isPending; - const closedHistory = (query.data?.history ?? []).filter( - (revision) => revision.validUntil !== null, - ); - - // A kind is dirty when its draft differs from the stored value and is not the - // empty placeholder. `isDirty` drives the render (Save enabled state); the - // footer re-collects the same set from the ref at click time. - const isDirty = (kind: HealthProfileFactKind, draft: string | undefined) => { - const current = query.data?.current[kind] ?? null; - return draft !== undefined && draft !== "" && draft !== current?.value; - }; - const dirtyCount = HEALTH_PROFILE_FACT_KINDS.filter((kind) => - isDirty(kind, drafts[kind]), - ).length; - const collectDirtyInputs = () => - HEALTH_PROFILE_FACT_KINDS.flatMap((kind) => { - const draft = draftsRef.current[kind]; - if (!isDirty(kind, draft)) return []; - return [ - { - kind, - value: draft as string, - current: query.data?.current[kind] ?? null, - }, - ]; - }); - - return ( -
-
- {HEALTH_PROFILE_FACT_KINDS.map((kind) => { - const current = query.data?.current[kind] ?? null; - const value = drafts[kind] ?? current?.value ?? ""; - const failed = failedKinds[kind] === true; - return ( -
- - - {/* Per-entry row now carries only the quiet destructive action: - saving is one shared footer button (UI-STANDARDS §11), so the - three fields no longer each stack their own grey Save slab. - When a shared Save partially fails, the kind that didn't land - says so here rather than behind the toast. */} - {failed || current ? ( -
- {failed ? ( - - {t("records.profileFacts.saveKindError")} - - ) : ( - - )} - {current ? ( - remove.mutate(current)} - pending={ - remove.isPending && remove.variables?.id === current.id - } - disabled={disabled} - variant="ghost" - size="sm" - className="text-destructive min-h-11 sm:min-h-9" - icon={} - slot={`health-profile-fact-remove-${kind}`} - /> - ) : null} -
- ) : null} -
- ); - })} -
- - {closedHistory.length > 0 && ( -
-

- {t("records.profileFacts.historyTitle")} -

-
    - {closedHistory.map((revision) => ( -
  • - - {t(KIND_LABEL[revision.kind])}:{" "} - {revision.unreadable - ? t("records.profileFacts.unreadable") - : revision.value - ? t( - `records.profileFacts.values.${revision.kind}.${revision.value}`, - ) - : t("records.profileFacts.notRecorded")} - - - {fmt.dateTime(revision.validFrom)} - {revision.validUntil ? ( - <> - {" "} - {t("records.profileFacts.to")}{" "} - {fmt.dateTime(revision.validUntil)} - - ) : null} - -
  • - ))} -
-
- )} - - {/* One Save for the whole tile. It writes only the dirty kinds and stays - disabled until at least one field differs from its stored value. It - is the last thing in the card, below the history (design standards - §12). */} - - - -
- ); -} diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx new file mode 100644 index 000000000..e5edccfef --- /dev/null +++ b/src/components/records/life-circumstances-manager.tsx @@ -0,0 +1,1074 @@ +"use client"; + +/** + * v1.43 (#614) — Settings → Anamnese → "Life circumstances". + * + * Six collapsible sections over `GET /api/anamnesis/life-circumstances`. Each + * section saves on its own (a section is one decision about one part of a + * life, so its Save sits at the end of its own body rather than at the end of + * the card), every question offers "no answer", follow-up questions only + * appear where they apply, and each section carries its "valid since", its + * AI switch and the quiet yearly "still true?" line. + * + * Everything shown as a number (pack-years, minutes per week, the AUDIT-C + * verdict) comes resolved from the server; this file renders it and never + * recomputes it. + */ +import { useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Loader2 } from "lucide-react"; + +import { AuditCPanel } from "@/components/records/audit-c-panel"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Switch } from "@/components/ui/switch"; +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances"; +import { queryKeys } from "@/lib/query-keys"; +import { + SHIFT_SCHEDULE_VALUES, + SMOKING_STATUS_VALUES, +} from "@/lib/validations/health-profile-facts"; +import { + DIET_PATTERNS, + DIET_SPECIFICS, + HEALTH_GOALS, + LATEST_CAFFEINE, + TOBACCO_PRODUCTS, + WORK_EXPOSURES, + WORK_PHYSICAL_NATURE, + WORK_SITUATIONS, + type ActivityHabitValue, + type FreeWeekdaysValue, + type HabitsValue, + type HealthGoalsValue, + type LifeAiSection, + type TobaccoDetailValue, + type WorkContextValue, +} from "@/lib/validations/life-circumstances"; + +type Section = LifeCircumstancesDto["sections"][number]; +type Fact = Section["facts"][number]; +type Translate = ( + key: string, + params?: Record, +) => string; + +const EMPTY = { + TOBACCO_DETAIL: { + schema: 1, + products: [], + cigarettesPerDay: null, + startYear: null, + quitMonth: null, + secondhandSmoke: null, + } as TobaccoDetailValue, + ACTIVITY_HABIT: { + schema: 1, + moderateDaysPerWeek: null, + moderateMinutesPerDay: null, + sittingHoursWorkday: null, + } as ActivityHabitValue, + WORK_CONTEXT: { + schema: 1, + situation: null, + physicalNature: null, + nightWork: null, + exposures: [], + } as WorkContextValue, + FREE_WEEKDAYS: { schema: 1, days: [] } as FreeWeekdaysValue, + HABITS: { + schema: 1, + napRegularly: null, + snoringReported: null, + dietPattern: null, + dietSpecifics: [], + lateMealsPerWeek: null, + fruitVegPortionsPerDay: null, + caffeineDrinksPerDay: null, + latestCaffeine: null, + } as HabitsValue, + HEALTH_GOALS: { schema: 1, goals: [] } as HealthGoalsValue, +}; +type LifeKind = keyof typeof EMPTY; +type Values = { + SMOKING_STATUS: string; + SHIFT_SCHEDULE: string; +} & { [K in LifeKind]: (typeof EMPTY)[K] }; + +/** An object value with nothing answered is "no answer" and stores nothing. */ +function isEmptyValue(value: Record): boolean { + return Object.entries(value).every( + ([key, v]) => + key === "schema" || v === null || (Array.isArray(v) && v.length === 0), + ); +} + +function currentMonth(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +function initialValues(facts: readonly Fact[]): Values { + const by = (kind: string) => + facts.find((fact) => fact.kind === kind && !fact.unreadable)?.value; + return { + SMOKING_STATUS: (by("SMOKING_STATUS") as string | undefined) ?? "", + SHIFT_SCHEDULE: (by("SHIFT_SCHEDULE") as string | undefined) ?? "", + TOBACCO_DETAIL: + (by("TOBACCO_DETAIL") as TobaccoDetailValue | undefined) ?? + EMPTY.TOBACCO_DETAIL, + ACTIVITY_HABIT: + (by("ACTIVITY_HABIT") as ActivityHabitValue | undefined) ?? + EMPTY.ACTIVITY_HABIT, + WORK_CONTEXT: + (by("WORK_CONTEXT") as WorkContextValue | undefined) ?? + EMPTY.WORK_CONTEXT, + FREE_WEEKDAYS: + (by("FREE_WEEKDAYS") as FreeWeekdaysValue | undefined) ?? + EMPTY.FREE_WEEKDAYS, + HABITS: (by("HABITS") as HabitsValue | undefined) ?? EMPTY.HABITS, + HEALTH_GOALS: + (by("HEALTH_GOALS") as HealthGoalsValue | undefined) ?? + EMPTY.HEALTH_GOALS, + }; +} + +/** + * Write one kind: POST a first answer, PATCH (a dated successor) a changed + * one, DELETE an answer cleared to "no answer". Unchanged kinds send nothing. + */ +async function writeKind( + kind: string, + next: string | Record, + current: Fact | undefined, + validFrom: string | undefined, +): Promise { + const empty = typeof next === "string" ? next === "" : isEmptyValue(next); + if (current && JSON.stringify(current.value) === JSON.stringify(next)) return; + if (empty) { + if (current) await apiDelete(`/api/anamnesis/facts/${current.id}`); + return; + } + if (current) { + await apiPatch(`/api/anamnesis/facts/${current.id}`, { + value: next, + ...(validFrom ? { validFrom } : {}), + }); + } else { + await apiPost("/api/anamnesis/facts", { + kind, + value: next, + ...(validFrom ? { validFrom } : {}), + }); + } +} + +// ── Small field primitives ─────────────────────────────────────────────────── + +function Field({ + id, + label, + children, +}: { + id: string; + label: string; + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function ChoiceField({ + id, + label, + value, + options, + onChange, + t, + disabled, +}: { + id: string; + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (value: string) => void; + t: Translate; + disabled: boolean; +}) { + return ( + + onChange(event.target.value)} + > + + {options.map((option) => ( + + ))} + + + ); +} + +function BoolField({ + id, + label, + value, + onChange, + t, + disabled, +}: { + id: string; + label: string; + value: boolean | null; + onChange: (value: boolean | null) => void; + t: Translate; + disabled: boolean; +}) { + return ( + onChange(next === "" ? null : next === "yes")} + t={t} + disabled={disabled} + /> + ); +} + +function NumberField({ + id, + label, + value, + min, + max, + step = 1, + onChange, + disabled, +}: { + id: string; + label: string; + value: number | null; + min: number; + max: number; + step?: number; + onChange: (value: number | null) => void; + disabled: boolean; +}) { + return ( + + { + const raw = event.target.value; + if (raw === "") return onChange(null); + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + onChange(Math.min(max, Math.max(min, parsed))); + } + }} + /> + + ); +} + +function MultiField({ + id, + label, + hint, + options, + value, + onChange, + max, + disabled, +}: { + id: string; + label: string; + hint?: string; + options: ReadonlyArray<{ value: T; label: string }>; + value: readonly T[]; + onChange: (value: T[]) => void; + max?: number; + disabled: boolean; +}) { + return ( +
+ {label} + {hint ?

{hint}

: null} +
+ {options.map((option) => { + const checked = value.includes(option.value); + const full = max !== undefined && value.length >= max && !checked; + return ( + + ); + })} +
+
+ ); +} + +/** Month and year of "valid since", as `YYYY-MM`. */ +function MonthField({ + id, + value, + onChange, + t, + disabled, +}: { + id: string; + value: string; + onChange: (value: string) => void; + t: Translate; + disabled: boolean; +}) { + const fmt = useFormatters(); + const [year, month] = value.split("-"); + const thisYear = new Date().getFullYear(); + const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i)); + return ( +
+ + {t("records.life.validSince")} + +
+ onChange(`${year}-${event.target.value}`)} + > + {Array.from({ length: 12 }, (_, i) => { + const m = String(i + 1).padStart(2, "0"); + return ( + + ); + })} + + onChange(`${event.target.value}-${month}`)} + > + {years.map((y) => ( + + ))} + +
+
+ ); +} + +/** The kinds a section writes, in the order it writes them. */ +const SECTION_KINDS: Record> = { + tobacco: ["SMOKING_STATUS", "TOBACCO_DETAIL"], + alcohol: [], + activity: ["ACTIVITY_HABIT"], + work: ["SHIFT_SCHEDULE", "WORK_CONTEXT", "FREE_WEEKDAYS"], + habits: ["HABITS"], + goals: ["HEALTH_GOALS"], +}; + +// ── One section ────────────────────────────────────────────────────────────── + +function SectionBody({ + section, + data, + t, + onSaved, +}: { + section: Section; + data: LifeCircumstancesDto; + t: Translate; + onSaved: () => void; +}) { + const fmt = useFormatters(); + const [values, setValues] = useState(() => + initialValues(section.facts), + ); + const [since, setSince] = useState(currentMonth); + const set = (kind: K, value: Values[K]) => + setValues((old) => ({ ...old, [kind]: value })); + const patch = ( + kind: K, + change: Partial<(typeof EMPTY)[K]>, + ) => setValues((old) => ({ ...old, [kind]: { ...old[kind], ...change } })); + + const save = useMutation({ + mutationKey: queryKeys.lifeCircumstances(), + mutationFn: async () => { + const validFrom = since === currentMonth() ? undefined : `${since}-01`; + for (const kind of SECTION_KINDS[section.key]) { + await writeKind( + kind, + values[kind] as string | Record, + section.facts.find((fact) => fact.kind === kind), + validFrom, + ); + } + }, + onSuccess: () => { + toastWrittenOutcome("success", t("records.life.savedToast")); + onSaved(); + }, + onError: (error) => { + onSaved(); + showErrorToast(t("records.life.saveError"), { cause: error }); + }, + }); + const disabled = save.isPending; + const id = (name: string) => `life-${section.key}-${name}`; + const opts = (list: readonly V[], ns: string) => + list.map((value) => ({ value, label: t(`${ns}.${value}`) })); + + let fields: ReactNode = null; + switch (section.key) { + case "tobacco": { + const v = values.TOBACCO_DETAIL; + fields = ( + <> + set("SMOKING_STATUS", next)} + t={t} + disabled={disabled} + /> + {values.SMOKING_STATUS !== "NEVER" ? ( + <> + + patch("TOBACCO_DETAIL", { + products, + ...(products.includes("cigarettes") + ? {} + : { cigarettesPerDay: null }), + }) + } + disabled={disabled} + /> +
+ {v.products.includes("cigarettes") ? ( + + patch("TOBACCO_DETAIL", { cigarettesPerDay }) + } + disabled={disabled} + /> + ) : null} + + patch("TOBACCO_DETAIL", { startYear }) + } + disabled={disabled} + /> +
+ {values.SMOKING_STATUS === "FORMER" ? ( + + { + const raw = event.target.value.trim(); + patch("TOBACCO_DETAIL", { + quitMonth: /^\d{4}-(0[1-9]|1[0-2])$/.test(raw) + ? raw + : raw === "" + ? null + : v.quitMonth, + }); + }} + /> + + ) : null} + + ) : null} + + patch("TOBACCO_DETAIL", { secondhandSmoke }) + } + t={t} + disabled={disabled} + /> + {data.derived.packYears.status === "computed" && + data.derived.packYears.value !== null ? ( +

+ {t("records.life.derived.packYears", { + value: fmt.number(data.derived.packYears.value, 1), + })} +

+ ) : data.derived.packYears.status === "missing_inputs" && + values.SMOKING_STATUS !== "" && + values.SMOKING_STATUS !== "NEVER" ? ( +

+ {t("records.life.derived.packYearsMissing")} +

+ ) : null} + + ); + break; + } + case "activity": { + const v = values.ACTIVITY_HABIT; + const minutes = data.derived.activityMinutesPerWeek; + fields = ( + <> + ({ + value: String(d), + label: String(d), + }))} + onChange={(next) => + patch("ACTIVITY_HABIT", { + moderateDaysPerWeek: next === "" ? null : Number(next), + }) + } + t={t} + disabled={disabled} + /> +
+ + patch("ACTIVITY_HABIT", { moderateMinutesPerDay }) + } + disabled={disabled} + /> + + patch("ACTIVITY_HABIT", { + sittingHoursWorkday: + hours === null ? null : Math.round(hours * 2) / 2, + }) + } + disabled={disabled} + /> +
+ {minutes.value !== null ? ( +

+ {t("records.life.derived.activityMinutes", { + value: fmt.integer(minutes.value), + })}{" "} + {minutes.meetsWho150 + ? t("records.life.derived.meetsWho") + : t("records.life.derived.belowWho")} +

+ ) : null} + {minutes.measuredStepsAvailable ? ( +

+ {t("records.life.derived.measuredSteps")} +

+ ) : null} + + ); + break; + } + case "work": { + const v = values.WORK_CONTEXT; + const shiftWork = + values.SHIFT_SCHEDULE === "FIXED_SHIFT" || + values.SHIFT_SCHEDULE === "ROTATING"; + fields = ( + <> +
+ + patch("WORK_CONTEXT", { + situation: + next === "" + ? null + : (next as WorkContextValue["situation"]), + }) + } + t={t} + disabled={disabled} + /> + + patch("WORK_CONTEXT", { + physicalNature: + next === "" + ? null + : (next as WorkContextValue["physicalNature"]), + }) + } + t={t} + disabled={disabled} + /> + set("SHIFT_SCHEDULE", next)} + t={t} + disabled={disabled} + /> + {shiftWork ? ( + patch("WORK_CONTEXT", { nightWork })} + t={t} + disabled={disabled} + /> + ) : null} +
+ patch("WORK_CONTEXT", { exposures })} + disabled={disabled} + /> + ({ + value: day, + label: t(`records.life.values.weekdays.${day}`), + }), + )} + value={ + values.FREE_WEEKDAYS.days.map(String) as Array< + "1" | "2" | "3" | "4" | "5" | "6" | "7" + > + } + onChange={(days) => + set("FREE_WEEKDAYS", { + schema: 1, + days: days.map(Number).sort((a, b) => a - b), + }) + } + disabled={disabled} + /> + + ); + break; + } + case "habits": { + const v = values.HABITS; + const num = ( + key: + | "lateMealsPerWeek" + | "fruitVegPortionsPerDay" + | "caffeineDrinksPerDay", + max: number, + ) => ( + patch("HABITS", { [key]: next })} + disabled={disabled} + /> + ); + fields = ( + <> +
+ patch("HABITS", { napRegularly })} + t={t} + disabled={disabled} + /> + + patch("HABITS", { snoringReported }) + } + t={t} + disabled={disabled} + /> + + patch("HABITS", { + dietPattern: + next === "" ? null : (next as HabitsValue["dietPattern"]), + }) + } + t={t} + disabled={disabled} + /> + + patch("HABITS", { + latestCaffeine: + next === "" + ? null + : (next as HabitsValue["latestCaffeine"]), + }) + } + t={t} + disabled={disabled} + /> + {num("lateMealsPerWeek", 7)} + {num("fruitVegPortionsPerDay", 15)} + {num("caffeineDrinksPerDay", 20)} +
+ patch("HABITS", { dietSpecifics })} + disabled={disabled} + /> + + ); + break; + } + case "goals": { + fields = ( + set("HEALTH_GOALS", { schema: 1, goals })} + disabled={disabled} + /> + ); + break; + } + case "alcohol": + return ; + } + + return ( +
+ {fields} + + + + +
+ ); +} + +// ── The card body ──────────────────────────────────────────────────────────── + +export function LifeCircumstancesManager() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: queryKeys.lifeCircumstances(), + queryFn: () => + apiGet("/api/anamnesis/life-circumstances"), + }); + + const refresh = () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.lifeCircumstances(), + }); + void queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.insightsAdvisor(), + }); + }; + + const aiSwitch = useMutation({ + mutationKey: queryKeys.lifeCircumstances(), + mutationFn: (change: { section: LifeAiSection; on: boolean }) => + apiPatch("/api/anamnesis/life-circumstances/ai-sections", { + sections: { [change.section]: change.on }, + }), + onSuccess: () => { + toastWrittenOutcome("success", t("records.life.ai.savedToast")); + refresh(); + }, + onError: (error) => + showErrorToast(t("records.life.ai.saveError"), { cause: error }), + }); + + const confirm = useMutation({ + mutationKey: queryKeys.lifeCircumstances(), + mutationFn: (section: Section["key"]) => + apiPost("/api/anamnesis/life-circumstances/confirm", { section }), + onSuccess: () => { + toastWrittenOutcome("success", t("records.life.review.confirmedToast")); + refresh(); + }, + onError: (error) => + showErrorToast(t("records.life.review.confirmError"), { cause: error }), + }); + + if (query.isError) { + return ( + void query.refetch()} + /> + ); + } + const data = query.data; + if (!data) { + return ( + + ); + } + + return ( +
+ {data.sections.map((raw) => { + const switchId = `life-ai-${raw.key}`; + return ( +
+ + + + {t(`records.life.sections.${raw.key}.title`)} + + + {raw.lastConfirmedAt + ? t("records.life.lastConfirmed", { + date: fmt.date(new Date(raw.lastConfirmedAt)), + }) + : t("records.life.notAnswered")} + + + + +
+ {raw.reviewDue ? ( +
+

+ {t("records.life.review.question")} +

+ +
+ ) : null} + {raw.facts.some((fact) => fact.unreadable) ? ( +

+ {t("records.life.unreadable")} +

+ ) : null} +
+ + + aiSwitch.mutate({ section: raw.aiSection, on }) + } + /> +
+ {raw.key === "work" ? ( +
+ + + aiSwitch.mutate({ section: "SHIFT_SCHEDULE", on }) + } + /> +
+ ) : null} +

+ {t("records.life.ai.hint")} +

+ fact.id))} + section={raw} + data={data} + t={t} + onSaved={refresh} + /> +
+
+ ); + })} +
+ ); +} diff --git a/src/components/records/screening-hints-card.tsx b/src/components/records/screening-hints-card.tsx new file mode 100644 index 000000000..e470a4bd4 --- /dev/null +++ b/src/components/records/screening-hints-card.tsx @@ -0,0 +1,80 @@ +"use client"; + +/** + * v1.43 (#614) — screening hints from the life circumstances, in Settings → + * Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound, + * each with its status, the reasons in the account's language and the rule's + * source and country. A hint, never a reminder: nothing is scheduled here, + * and the card says so. Resolved on the server; rendered as delivered. + * + * The card stays away until the person has said something about smoking, + * because every hint would otherwise read "not enough information". + */ +import { useQuery } from "@tanstack/react-query"; +import { Stethoscope } from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances"; +import { queryKeys } from "@/lib/query-keys"; + +export function ScreeningHintsCard() { + const { t } = useTranslations(); + const query = useQuery({ + queryKey: queryKeys.lifeCircumstances(), + queryFn: () => + apiGet("/api/anamnesis/life-circumstances"), + }); + const data = query.data; + const tobacco = data?.sections.find((section) => section.key === "tobacco"); + if (!data || !tobacco || tobacco.facts.length === 0) return null; + + return ( + + +
    + {data.derived.screeningHints.map((hint) => ( +
  • +

    + {t(`records.life.screening.titles.${hint.id}`)} +

    +

    + {t(`records.life.screening.status.${hint.status}`)} + {hint.reasonKeys.length > 0 + ? ` ${hint.reasonKeys + .map((key) => t(`records.life.screening.reasons.${key}`)) + .join(" ")}` + : ""} +

    +

    + + {t("records.life.screening.source", { + source: hint.sourceLabel, + })} + +

    +
  • + ))} +
+

+ {t("records.life.screening.noReminder")} +

+
+ ); +} diff --git a/src/components/settings/anamnesis-section.tsx b/src/components/settings/anamnesis-section.tsx index caec4af76..d823f5e49 100644 --- a/src/components/settings/anamnesis-section.tsx +++ b/src/components/settings/anamnesis-section.tsx @@ -40,7 +40,7 @@ import { ConditionsManager } from "@/components/records/conditions-manager"; import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager"; import { FamilyHistoryManager } from "@/components/records/family-history-manager"; import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager"; -import { HealthProfileFactsManager } from "@/components/records/health-profile-facts-manager"; +import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager"; export function AnamnesisSection() { const { t } = useTranslations(); @@ -99,14 +99,17 @@ export function AnamnesisSection() { )} + {/* v1.43 (#614) — the life circumstances took over the three profile + facts (smoking status and shift work live in their sections now; + alcohol is AUDIT-C). */} - + diff --git a/src/components/settings/vorsorge-section.tsx b/src/components/settings/vorsorge-section.tsx index e53a1c059..c6413b905 100644 --- a/src/components/settings/vorsorge-section.tsx +++ b/src/components/settings/vorsorge-section.tsx @@ -12,11 +12,14 @@ */ import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings"; +import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function VorsorgeSection() { return (
+ {/* v1.43 (#614) — hints from the life circumstances, with their source. */} +
); } diff --git a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts index b3c92adec..e934c9304 100644 --- a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts @@ -13,6 +13,7 @@ * read plus the shared accumulators, so the emitted shape and ordering * are unchanged. */ +import type { FreeWeekdaysSpan } from "@/lib/profile/life-derived"; import { reconstructSleepNights, type SleepStageRow, @@ -158,6 +159,8 @@ interface SleepRhythmBlockContext { sourcePriorityJson: unknown; userTz: string; ageYears: number | null; + /** v1.43 — the record's dated free-weekday answers for the day type. */ + freeWeekdaySpans: readonly FreeWeekdaysSpan[]; snapshot: Record; metrics: Set; registerBlock: (key: string, source: CoachScopeSource) => void; @@ -192,6 +195,7 @@ export function buildSleepRhythmBlock( sourcePriorityJson, userTz, ageYears, + freeWeekdaySpans, snapshot, metrics, registerBlock, @@ -199,6 +203,8 @@ export function buildSleepRhythmBlock( const rhythm: SleepRhythmDto = computeSleepRhythmFromNights( reconstructNights(sleepRhythmRows, userTz, sourcePriorityJson), sleepNeedMinutes(ageYears), + "COMPUTED", + freeWeekdaySpans, ); const chronotypeReady = rhythm.chronotype.state === "ready"; snapshot.sleepRhythm = { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 04e37f92b..4c2a53dd8 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -15,6 +15,7 @@ * values with weekday labels and aggregates the older window into * weekly buckets so the prompt budget stays tight. */ +import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; import type { CoachDataCluster } from "@/lib/validations/coach-prefs"; @@ -806,6 +807,7 @@ async function buildCoachSnapshotImpl( sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null, userTz, ageYears: derivedProfile.ageYears, + freeWeekdaySpans: await loadFreeWeekdaySpans(userId), snapshot, metrics, registerBlock, diff --git a/src/lib/ai/coach/tools/environment-read.ts b/src/lib/ai/coach/tools/environment-read.ts index 51e1e81c2..751b6e2f5 100644 --- a/src/lib/ai/coach/tools/environment-read.ts +++ b/src/lib/ai/coach/tools/environment-read.ts @@ -39,6 +39,8 @@ import { } from "@/lib/ai/coach/history-reach"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; import { shiftDateKey } from "@/lib/tz/format"; +import { decryptLifeFactValue } from "@/lib/profile/health-facts"; +import type { WorkContextValue } from "@/lib/validations/life-circumstances"; /** Days listed one by one; the summary covers the whole window. */ export const ENVIRONMENT_DAILY_LIMIT = 31; @@ -103,6 +105,17 @@ export interface EnvironmentToolData { pollenPeak?: number | null; }; airQuality: EnvironmentAirQualityState; + /** + * v1.43 (#614) — present only when the person released the work section + * to the AI and said they work outdoors (or partly) or with heat, dust or + * fumes: hot and poor-air days then weigh more for them. A self-report, + * never a measured exposure. + */ + workContext?: { + outdoorWork: boolean; + exposures: string[]; + note: string; + }; provenance: { weather: string; airQuality: string; @@ -151,6 +164,47 @@ function dayKey(date: Date): string { return date.toISOString().slice(0, 10); } +const RELEVANT_EXPOSURES = ["heat", "dust", "fumes_solvents"] as const; + +/** + * The person's work context as far as it bears on weather and air: only with + * the work section released to the AI (the same switch the self-context block + * honours), and only when it says something the environment can weigh. + */ +async function loadWorkContextForEnvironment( + userId: string, +): Promise { + const profile = await prisma.userHealthProfile.findUnique({ + where: { userId }, + select: { aiIncludedSections: true }, + }); + if (!profile?.aiIncludedSections.includes("WORK")) return null; + const row = await prisma.healthProfileFactRevision.findFirst({ + where: { + userId, + kind: "WORK_CONTEXT", + validUntil: null, + supersededByRevisionId: null, + }, + select: { valueEncrypted: true }, + }); + if (!row) return null; + const work = decryptLifeFactValue("WORK_CONTEXT", row.valueEncrypted) + .value as WorkContextValue | null; + if (!work) return null; + const outdoorWork = + work.situation === "outdoor" || work.situation === "mixed"; + const exposures = work.exposures.filter((exposure) => + (RELEVANT_EXPOSURES as readonly string[]).includes(exposure), + ); + if (!outdoorWork && exposures.length === 0) return null; + return { + outdoorWork, + exposures, + note: "Self-reported work context: hot nights, heat and very poor air days weigh more for this person than for someone working indoors. Context, never a measured exposure.", + }; +} + /** * Read the stored environment days for the tool. Never throws for "nothing * there"; a database error propagates to the executor, which reports @@ -314,6 +368,7 @@ export async function readEnvironmentForTool(args: { const firstDate = rows[0].date; const lastDate = rows[rows.length - 1].date; + const workContext = await loadWorkContextForEnvironment(userId); return { present: true, data: { @@ -331,6 +386,7 @@ export async function readEnvironmentForTool(args: { ...(airActive ? { veryPoorAirDays, highPollenDays, pollenPeak } : {}), }, airQuality: airState, + ...(workContext ? { workContext } : {}), provenance: PROVENANCE, attributions: environmentAttributionLines({ airQuality: airActive, diff --git a/src/lib/command-palette/settings-cards.ts b/src/lib/command-palette/settings-cards.ts index 3f7317db2..0a6a248b0 100644 --- a/src/lib/command-palette/settings-cards.ts +++ b/src/lib/command-palette/settings-cards.ts @@ -281,8 +281,8 @@ export const SETTINGS_CARDS: ReadonlyArray = [ }, { section: "anamnesis", - anchor: "profile-facts", - titleKey: "records.profileFacts.cardTitle", + anchor: "life-circumstances", + titleKey: "records.life.cardTitle", }, { section: "anamnesis", diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index 2af5f9966..630fd469e 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -47,6 +47,11 @@ import { resolveSleepDebtSource, } from "@/lib/validations/source-priority"; import { loadBaselineProfile } from "./baseline"; +import { + dayTypeForNight, + type FreeWeekdaysSpan, +} from "@/lib/profile/life-derived"; +import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; import { reconstructNights, sleepNeedMinutes } from "./sleep-score"; import { computeSleepDebt, @@ -255,6 +260,10 @@ export function computeSleepRhythmFromNights( // COMPUTED (the only producer today) so the dashboard + Coach snapshot // callers, which always surface our own estimate, need not thread it. sleepDebtSource: MeasurementSource = "COMPUTED", + // v1.43 (#614) — the record's dated "free weekdays" answers. Each night is + // read against the answer in force on its wake day; a night no answer + // covers keeps the weekend default. + freeWeekdaySpans: readonly FreeWeekdaysSpan[] = [], ): SleepRhythmDto { // Sort ascending by wake-day key so "trailing N" is well-defined regardless // of the caller's input order, then keep only scorable nights. @@ -279,7 +288,7 @@ export function computeSleepRhythmFromNights( night: n.night, midpointMinutes: n.midpoint as number, asleepMinutes: n.asleepMinutes, - dayType: defaultDayType(n.night), + dayType: dayTypeForNight(n.night, freeWeekdaySpans), })); const chronotype = computeChronotype(chronoNights); @@ -310,9 +319,10 @@ export async function buildSleepRhythm( const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; const now = opts.now ?? new Date(); const tz = opts.tz ?? (await resolveUserTimezone(userId)); - const [priorityJson, profile] = await Promise.all([ + const [priorityJson, profile, freeWeekdaySpans] = await Promise.all([ loadUserSourcePriority(userId), loadBaselineProfile(prisma, userId), + loadFreeWeekdaySpans(userId), ]); const since = new Date(now.getTime() - windowDays * MS_PER_DAY); @@ -347,5 +357,10 @@ export async function buildSleepRhythm( const sleepDebtSource = resolveSleepDebtSource( parseSourcePriority(priorityJson), ); - return computeSleepRhythmFromNights(nights, needMinutes, sleepDebtSource); + return computeSleepRhythmFromNights( + nights, + needMinutes, + sleepDebtSource, + freeWeekdaySpans, + ); } diff --git a/src/lib/profile/health-facts.ts b/src/lib/profile/health-facts.ts index df645e2c4..4fcd9a452 100644 --- a/src/lib/profile/health-facts.ts +++ b/src/lib/profile/health-facts.ts @@ -3,7 +3,10 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { invalidateUserInsights } from "@/lib/cache/invalidate"; +import { + invalidateUserDashboardSnapshot, + invalidateUserInsights, +} from "@/lib/cache/invalidate"; import { getEvent } from "@/lib/logging/context"; import { HEALTH_PROFILE_FACT_KINDS, @@ -70,6 +73,8 @@ export async function invalidateHealthProfileFactConsumers( }, }); invalidateUserInsights(userId); + // The dashboard's chronotype reads the free-weekday answers (v1.43). + invalidateUserDashboardSnapshot(userId); } export function decryptHealthProfileFactValue( diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index ff27aaacb..b92285b1e 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -11,6 +11,8 @@ export const coachKeys = { coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const, /** Encrypted, effective-dated structured anamnesis facts. */ healthProfileFacts: () => ["health-profile-facts"] as const, + /** v1.43 (#614) — the life-circumstance sections, derived values and AUDIT-C. */ + lifeCircumstances: () => ["life-circumstances"] as const, /** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */ coachNudgeStatus: () => ["coach-nudge-status"] as const, /** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */