mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(anamnesis): life-circumstances card with AUDIT-C, screening hints and free-text take-over
This commit is contained in:
1 parent
db750ef246
commit
28a8737124
27 files changed
+3427
-877
No files matched your search
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
+236
-15
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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<string, unknown>[],
|
||||
renderedButtons: [] as Record<string, unknown>[],
|
||||
selects: [] as Record<string, unknown>[],
|
||||
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<string, unknown>) => {
|
||||
renderedButtons.push(props);
|
||||
return <button />;
|
||||
},
|
||||
}));
|
||||
|
||||
// The real Radix Select hides its `onValueChange` behind the trigger, so the
|
||||
// draft edits the interactions drive can't reach it. A thin mock exposes the
|
||||
// callback (kinds render in `HEALTH_PROFILE_FACT_KINDS` order) so a test can set
|
||||
// a draft the one footer Save then writes.
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: (props: Record<string, unknown>) => {
|
||||
selects.push(props);
|
||||
return <div>{props.children as ReactNode}</div>;
|
||||
},
|
||||
SelectContent: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectItem: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectTrigger: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/confirm-button", () => ({
|
||||
ConfirmButton: (props: Record<string, unknown>) => {
|
||||
confirmButtons.push(props);
|
||||
return (
|
||||
<button data-slot={String(props.slot)}>{String(props.label)}</button>
|
||||
);
|
||||
},
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthProfileFactsManager />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,18 @@ const SECTION_LABEL_KEY: Record<HealthProfileAiSection, string> = {
|
||||
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<HealthProfileAiSection> = 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 (
|
||||
<div className="space-y-4" data-slot="ai-profile-inclusion-manager">
|
||||
<div className="divide-border divide-y">
|
||||
{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 (
|
||||
<div
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -142,6 +143,8 @@ export function AllergyFreeTextNote({
|
||||
) : (
|
||||
<SettingsCardActions>
|
||||
{leadingAction}
|
||||
{/* v1.43 (#614) — move the free text into a structured entry by hand. */}
|
||||
<FreeTextTakeOver field="allergies" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — the alcohol section of the life circumstances: AUDIT-C.
|
||||
*
|
||||
* The three items render exactly as the server delivers them
|
||||
* (`auditC.instrument`): validated wording in the account language, or the
|
||||
* WHO English original with a note when no validated translation exists.
|
||||
* This file never carries item text of its own. The result line, the cut-off
|
||||
* and its source are the server's too; nothing is scored here.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiDelete, 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";
|
||||
|
||||
type AuditC = LifeCircumstancesDto["auditC"];
|
||||
|
||||
export function AuditCPanel({
|
||||
data,
|
||||
onChanged,
|
||||
}: {
|
||||
data: AuditC;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [answers, setAnswers] = useState<Array<number | null>>([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const { instrument, latest } = data;
|
||||
|
||||
const submit = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: (items: number[]) =>
|
||||
apiPost("/api/anamnesis/audit-c", {
|
||||
items,
|
||||
// The language of the wording actually answered; the cut-off follows it.
|
||||
locale: instrument.locale,
|
||||
source: "WEB",
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.auditC.savedToast"));
|
||||
setOpen(false);
|
||||
setAnswers([null, null, null]);
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: (id: string) => apiDelete(`/api/anamnesis/audit-c/${id}`),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.auditC.removedToast"));
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.removeError"), { cause: error }),
|
||||
});
|
||||
|
||||
const complete = answers.every((answer) => answer !== null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="audit-c-panel">
|
||||
<p className="text-sm">{t("records.life.auditC.intro")}</p>
|
||||
|
||||
{latest ? (
|
||||
<div className="space-y-1" data-slot="audit-c-latest">
|
||||
<p className="text-sm font-medium">
|
||||
{t("records.life.auditC.result", { score: latest.totalScore })}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{" "}
|
||||
{fmt.date(new Date(latest.takenAt))}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{latest.cutoff === null
|
||||
? t("records.life.auditC.noCutoff")
|
||||
: t(
|
||||
latest.aboveCutoff
|
||||
? "records.life.auditC.above"
|
||||
: "records.life.auditC.below",
|
||||
{ cutoff: latest.cutoff.value },
|
||||
)}
|
||||
</p>
|
||||
{latest.cutoff ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={latest.cutoff.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{t("records.life.auditC.source", {
|
||||
source: latest.cutoff.sourceLabel,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.history.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("records.life.auditC.history")}
|
||||
</h4>
|
||||
<ul className="divide-border divide-y text-sm">
|
||||
{data.history.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-2 py-1.5"
|
||||
>
|
||||
<span>
|
||||
{t("records.life.auditC.historyEntry", {
|
||||
date: fmt.date(new Date(entry.takenAt)),
|
||||
score: entry.totalScore,
|
||||
})}
|
||||
</span>
|
||||
<ConfirmButton
|
||||
label={t("records.life.auditC.remove")}
|
||||
ariaLabel={t("records.life.auditC.remove")}
|
||||
title={t("records.life.auditC.removeConfirmTitle")}
|
||||
body={t("records.life.auditC.removeConfirmBody")}
|
||||
confirmLabel={t("records.life.auditC.remove")}
|
||||
onConfirm={() => remove.mutate(entry.id)}
|
||||
pending={remove.isPending && remove.variables === entry.id}
|
||||
disabled={remove.isPending}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`audit-c-remove-${entry.id}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.offerDue ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.offerDue")}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={data.offerDue ? "default" : "outline"}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
data-slot="audit-c-start"
|
||||
>
|
||||
{latest
|
||||
? t("records.life.auditC.again")
|
||||
: t("records.life.auditC.start")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("records.life.sections.alcohol.title")}
|
||||
description={t("records.life.auditC.intro")}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!complete || submit.isPending}
|
||||
onClick={() => submit.mutate(answers as number[])}
|
||||
data-slot="audit-c-submit"
|
||||
>
|
||||
{submit.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.life.auditC.submit")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6" lang={instrument.locale}>
|
||||
{!instrument.validatedLocale ? (
|
||||
<p className="text-sm" lang={locale}>
|
||||
{t("records.life.auditC.notValidated")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm" lang={locale}>
|
||||
{instrument.standardDrink}
|
||||
</p>
|
||||
{instrument.items.map((item, index) => (
|
||||
<fieldset key={index} className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{index + 1}. {item.stem}
|
||||
</legend>
|
||||
<div className="space-y-1">
|
||||
{item.options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 items-center gap-2 text-sm sm:min-h-9"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`audit-c-${index}`}
|
||||
value={option.value}
|
||||
checked={answers[index] === option.value}
|
||||
onChange={() =>
|
||||
setAnswers((old) =>
|
||||
old.map((value, i) =>
|
||||
i === index ? option.value : value,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="accent-primary size-4"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="text-muted-foreground text-xs" lang="en">
|
||||
{instrument.attribution}
|
||||
</p>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
{/* v1.43 (#614) — move the free text into the condition journal by hand. */}
|
||||
<FreeTextTakeOver field="conditions" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — turn the free-text conditions or allergies line into a
|
||||
* structured entry, by hand.
|
||||
*
|
||||
* The free text and the structured records overlap: the chronic conditions
|
||||
* live in the condition journal (`IllnessEpisode`, `CHRONIC_ONGOING`), the
|
||||
* allergies in `Allergy`. Nothing here parses the free text or merges
|
||||
* anything on its own. The button opens a form beside the text; the person
|
||||
* types the entry, it is saved through the ordinary create route, and only
|
||||
* then is clearing the free text offered, as a separate decision.
|
||||
*
|
||||
* The button shows only while there is free text and the target is
|
||||
* available (`takeOver` from the life-circumstances read).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { AllergyForm } from "@/components/records/allergy-form";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
interface AboutMeData {
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export function FreeTextTakeOver({
|
||||
field,
|
||||
}: {
|
||||
field: "conditions" | "allergies";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [onset, setOnset] = useState("");
|
||||
const [footerEl, setFooterEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const life = useQuery({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
queryFn: () =>
|
||||
apiGet<LifeCircumstancesDto>("/api/anamnesis/life-circumstances"),
|
||||
});
|
||||
const aboutMe = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
|
||||
});
|
||||
|
||||
const afterCreate = () => {
|
||||
setSaved(true);
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.savedToast"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
field === "conditions" ? queryKeys.illness() : queryKeys.allergies(),
|
||||
});
|
||||
};
|
||||
|
||||
const createCondition = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: () =>
|
||||
apiPost("/api/illness/episodes", {
|
||||
label: label.trim(),
|
||||
type: "CHRONIC",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
...(isCalendarDateKey(onset)
|
||||
? { onsetAt: dateOnlyAtNoonUtc(onset).toISOString() }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: afterCreate,
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.takeOver.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const clear = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: () =>
|
||||
apiPut(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined),
|
||||
),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
|
||||
close();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.takeOver.clearError"), { cause: error }),
|
||||
});
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setSaved(false);
|
||||
setLabel("");
|
||||
setOnset("");
|
||||
}
|
||||
|
||||
const entry = life.data?.takeOver.find((item) => item.field === field);
|
||||
const text = aboutMe.data?.[field] ?? null;
|
||||
if (!entry?.freeTextPresent || !entry.available || !text) return null;
|
||||
|
||||
const title =
|
||||
field === "conditions"
|
||||
? t("records.life.takeOver.conditionsTitle")
|
||||
: t("records.life.takeOver.allergiesTitle");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
data-slot={`take-over-${field}`}
|
||||
>
|
||||
{field === "conditions"
|
||||
? t("records.life.takeOver.conditionsButton")
|
||||
: t("records.life.takeOver.allergiesButton")}
|
||||
</Button>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => (next ? setOpen(true) : close())}
|
||||
title={title}
|
||||
description={t("records.life.takeOver.description")}
|
||||
footer={
|
||||
saved ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={close}
|
||||
>
|
||||
{t("records.life.takeOver.keep")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate()}
|
||||
>
|
||||
{t("records.life.takeOver.clear")}
|
||||
</Button>
|
||||
</>
|
||||
) : field === "conditions" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={label.trim().length === 0 || createCondition.isPending}
|
||||
onClick={() => createCondition.mutate()}
|
||||
>
|
||||
{createCondition.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.life.takeOver.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<div ref={setFooterEl} className="flex w-full justify-end gap-2" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("records.life.takeOver.freeTextLabel")}
|
||||
</p>
|
||||
<p className="bg-muted/40 rounded-md p-3 text-sm whitespace-pre-wrap">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
{saved ? (
|
||||
<p className="text-sm">
|
||||
{t("records.life.takeOver.clearQuestion")}
|
||||
</p>
|
||||
) : field === "conditions" ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="take-over-condition-label">
|
||||
{t("records.life.takeOver.conditionLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="take-over-condition-label"
|
||||
maxLength={120}
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="take-over-condition-onset">
|
||||
{t("records.life.takeOver.onset")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="take-over-condition-onset"
|
||||
value={onset}
|
||||
onChange={setOnset}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AllergyForm
|
||||
footerSlot={footerEl}
|
||||
onSuccess={afterCreate}
|
||||
onCancel={close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<HealthProfileFactKind, FactDto | null>;
|
||||
history: FactDto[];
|
||||
}
|
||||
|
||||
interface RemovedFactDto {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
removedAt: string;
|
||||
}
|
||||
|
||||
const VALUES: Record<HealthProfileFactKind, readonly string[]> = {
|
||||
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<HealthProfileFactKind, string> = {
|
||||
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<Record<HealthProfileFactKind, string>>
|
||||
>({});
|
||||
// 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<Record<HealthProfileFactKind, boolean>>
|
||||
>({});
|
||||
// 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<Partial<Record<HealthProfileFactKind, string>>>({});
|
||||
|
||||
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<FactsData>("/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<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
|
||||
value: input.value,
|
||||
})
|
||||
: apiPost<FactDto>("/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<RemovedFactDto>(`/api/anamnesis/facts/${current.id}`),
|
||||
onSuccess: (removed) => {
|
||||
setDrafts((current) => ({ ...current, [removed.kind]: undefined }));
|
||||
queryClient.setQueryData<FactsData>(
|
||||
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 (
|
||||
<QueryErrorCard
|
||||
title={t("records.profileFacts.loadError")}
|
||||
onRetry={() => 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 (
|
||||
<div className="space-y-6" data-slot="health-profile-facts-manager">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{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 (
|
||||
<div key={kind} className="space-y-2">
|
||||
<Label htmlFor={`health-profile-fact-${kind}`}>
|
||||
{t(KIND_LABEL[kind])}
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => editDraft(kind, next)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`health-profile-fact-${kind}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
current?.unreadable
|
||||
? t("records.profileFacts.unreadable")
|
||||
: t("records.profileFacts.notRecorded")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VALUES[kind].map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`records.profileFacts.values.${kind}.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 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 ? (
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
{failed ? (
|
||||
<span
|
||||
className="text-destructive text-xs"
|
||||
data-slot={`health-profile-fact-error-${kind}`}
|
||||
>
|
||||
{t("records.profileFacts.saveKindError")}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
{current ? (
|
||||
<ConfirmButton
|
||||
// Short label inside the row; the full phrase stays the
|
||||
// accessible name, because "Remove" on its own is
|
||||
// ambiguous once three of them are on screen.
|
||||
label={t("records.profileFacts.removeShort")}
|
||||
ariaLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
title={t("records.profileFacts.removeConfirmTitle", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
body={t("records.profileFacts.removeConfirmDescription")}
|
||||
confirmLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
onConfirm={() => 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={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`health-profile-fact-remove-${kind}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{closedHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("records.profileFacts.historyTitle")}
|
||||
</h3>
|
||||
<ul className="divide-border divide-y text-sm">
|
||||
{closedHistory.map((revision) => (
|
||||
<li
|
||||
key={revision.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2"
|
||||
>
|
||||
<span>
|
||||
{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")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(revision.validFrom)}
|
||||
{revision.validUntil ? (
|
||||
<>
|
||||
{" "}
|
||||
{t("records.profileFacts.to")}{" "}
|
||||
{fmt.dateTime(revision.validUntil)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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). */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled || dirtyCount === 0}
|
||||
onClick={() => save.mutate(collectDirtyInputs())}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.profileFacts.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<LifeCircumstancesDto>("/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 (
|
||||
<SettingsCard data-slot="screening-hints-card">
|
||||
<SettingsCardHeader
|
||||
anchor="screening-hints"
|
||||
icon={Stethoscope}
|
||||
title={t("records.life.screening.cardTitle")}
|
||||
description={t("records.life.screening.cardDescription")}
|
||||
/>
|
||||
<ul className="divide-border divide-y">
|
||||
{data.derived.screeningHints.map((hint) => (
|
||||
<li
|
||||
key={hint.id}
|
||||
className="space-y-1 py-3"
|
||||
data-slot={`screening-hint-${hint.id}`}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t(`records.life.screening.titles.${hint.id}`)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t(`records.life.screening.status.${hint.status}`)}
|
||||
{hint.reasonKeys.length > 0
|
||||
? ` ${hint.reasonKeys
|
||||
.map((key) => t(`records.life.screening.reasons.${key}`))
|
||||
.join(" ")}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={hint.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{t("records.life.screening.source", {
|
||||
source: hint.sourceLabel,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.screening.noReminder")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* 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). */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="profile-facts"
|
||||
anchor="life-circumstances"
|
||||
icon={ClipboardList}
|
||||
title={t("records.profileFacts.cardTitle")}
|
||||
description={t("records.profileFacts.cardDescription")}
|
||||
title={t("records.life.cardTitle")}
|
||||
description={t("records.life.cardDescription")}
|
||||
/>
|
||||
<HealthProfileFactsManager />
|
||||
<LifeCircumstancesManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
*/
|
||||
|
||||
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
|
||||
|
||||
export function VorsorgeSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<VorsorgeSettings />
|
||||
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
|
||||
<ScreeningHintsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EnvironmentToolData["workContext"] | null> {
|
||||
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,
|
||||
|
||||
@@ -281,8 +281,8 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "profile-facts",
|
||||
titleKey: "records.profileFacts.cardTitle",
|
||||
anchor: "life-circumstances",
|
||||
titleKey: "records.life.cardTitle",
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user