feat(anamnesis): life-circumstances card with AUDIT-C, screening hints and free-text take-over

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 01:09:50 +02:00
1 parent db750ef246
commit 28a8737124
27 files changed
+3427 -877

No files matched your search

+236 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+3 -1
View File
@@ -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;
+3
View File
@@ -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"
+248
View File
@@ -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 = {
+2
View File
@@ -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,
+2 -2
View File
@@ -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",
+18 -3
View File
@@ -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,
);
}
+6 -1
View File
@@ -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(
+2
View File
@@ -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). */