Merge branch 'v143/life' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 03:39:40 +02:00
commit 4daad8f2fb
88 files changed
+8313 -1386

No files matched your search

+603 -141
View File
@@ -10374,8 +10374,9 @@ paths:
- Insights
summary: Read effective-dated anamnesis facts
description: "Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern
and shift schedule. Values are decrypted server-side; unreadable ciphertext returns `value: null` with
`unreadable: true`."
and shift schedule, and only those three kinds: the life-circumstance kinds are read through `GET
/api/anamnesis/life-circumstances`. Values are decrypted server-side; unreadable ciphertext returns `value:
null` with `unreadable: true`."
responses:
"200":
description: Current facts and their revision history.
@@ -10391,8 +10392,11 @@ paths:
tags:
- Insights
summary: Create an anamnesis fact
description: Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A
second current value for the same kind returns 409; corrections use the revision endpoint.
description: "Creates the first current revision for one fact kind. The answer is encrypted before persistence. The
three closed kinds take their string value and answer the closed revision shape; the six life-circumstance kinds
(v1.43, send only when `features.lifeCircumstances` is true) take an object with `schema: 1` and answer a
`LifeFactRevision`. Optional `validFrom` (a calendar day) dates the answer back. A second current value for the
same kind returns 409; corrections use the revision endpoint."
parameters:
- *a6
requestBody:
@@ -10412,6 +10416,13 @@ paths:
- NEVER
- FORMER
- CURRENT
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
@@ -10427,6 +10438,13 @@ paths:
- OCCASIONAL
- WEEKLY
- MOST_DAYS
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
@@ -10441,6 +10459,338 @@ paths:
- NONE
- FIXED_SHIFT
- ROTATING
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: TOBACCO_DETAIL
value:
type: object
properties:
schema:
type: number
const: 1
products:
maxItems: 5
type: array
items:
type: string
enum:
- cigarettes
- cigars_pipe
- e_cigarette
- heated_tobacco
- snus_pouches
cigarettesPerDay:
anyOf:
- type: integer
minimum: 1
maximum: 100
- type: "null"
startYear:
anyOf:
- type: integer
minimum: 1900
maximum: 9007199254740991
- type: "null"
quitMonth:
anyOf:
- type: string
pattern: ^\d{4}-(0[1-9]|1[0-2])$
- type: "null"
secondhandSmoke:
anyOf:
- type: boolean
- type: "null"
required:
- schema
- products
- cigarettesPerDay
- startYear
- quitMonth
- secondhandSmoke
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: ACTIVITY_HABIT
value:
type: object
properties:
schema:
type: number
const: 1
moderateDaysPerWeek:
anyOf:
- type: integer
minimum: 0
maximum: 7
- type: "null"
moderateMinutesPerDay:
anyOf:
- type: integer
minimum: 0
maximum: 600
- type: "null"
sittingHoursWorkday:
anyOf:
- type: number
minimum: 0
maximum: 24
multipleOf: 0.5
- type: "null"
required:
- schema
- moderateDaysPerWeek
- moderateMinutesPerDay
- sittingHoursWorkday
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: WORK_CONTEXT
value:
type: object
properties:
schema:
type: number
const: 1
situation:
anyOf:
- type: string
enum:
- none
- indoor
- outdoor
- mixed
- type: "null"
physicalNature:
anyOf:
- type: string
enum:
- mostly_sitting
- standing_walking
- heavy_physical
- type: "null"
nightWork:
anyOf:
- type: boolean
- type: "null"
exposures:
maxItems: 5
type: array
items:
type: string
enum:
- heat
- cold
- dust
- fumes_solvents
- noise
required:
- schema
- situation
- physicalNature
- nightWork
- exposures
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: FREE_WEEKDAYS
value:
type: object
properties:
schema:
type: number
const: 1
days:
maxItems: 7
type: array
items:
type: integer
minimum: 1
maximum: 7
required:
- schema
- days
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: HABITS
value:
type: object
properties:
schema:
type: number
const: 1
napRegularly:
anyOf:
- type: boolean
- type: "null"
snoringReported:
anyOf:
- type: boolean
- type: "null"
dietPattern:
anyOf:
- type: string
enum:
- mixed
- vegetarian
- vegan
- pescetarian
- other
- type: "null"
dietSpecifics:
maxItems: 3
type: array
items:
type: string
enum:
- intermittent_fasting
- low_carb
- prescribed_diet
lateMealsPerWeek:
anyOf:
- type: integer
minimum: 0
maximum: 7
- type: "null"
fruitVegPortionsPerDay:
anyOf:
- type: integer
minimum: 0
maximum: 15
- type: "null"
caffeineDrinksPerDay:
anyOf:
- type: integer
minimum: 0
maximum: 20
- type: "null"
latestCaffeine:
anyOf:
- type: string
enum:
- before_12
- 12_15
- 15_18
- after_18
- type: "null"
required:
- schema
- napRegularly
- snoringReported
- dietPattern
- dietSpecifics
- lateMealsPerWeek
- fruitVegPortionsPerDay
- caffeineDrinksPerDay
- latestCaffeine
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
- type: object
properties:
kind:
type: string
const: HEALTH_GOALS
value:
type: object
properties:
schema:
type: number
const: 1
goals:
maxItems: 3
type: array
items:
type: string
enum:
- weight
- blood_pressure
- blood_sugar
- sleep
- fitness
- stress_recovery
- medication_routine
- quit_smoking
- less_alcohol
required:
- schema
- goals
additionalProperties: false
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- kind
- value
@@ -10466,8 +10816,10 @@ paths:
tags:
- Insights
summary: Correct a current anamnesis fact
description: Closes the current revision at the correction instant and creates a successor. Prior ciphertext and its
half-open validity interval remain in history.
description: "Closes the current revision at the correction instant (or at the optional `validFrom` day, never earlier
than the revision it replaces) and creates a successor. The value takes the stored kind's form: a string for the
closed kinds, an object with `schema: 1` for the life-circumstance kinds; any other is 422
`anamnesis.fact.invalidValue`. Prior ciphertext and its half-open validity interval remain in history."
parameters:
- name: id
in: path
@@ -10482,8 +10834,20 @@ paths:
type: object
properties:
value:
anyOf:
- type: string
minLength: 1
- type: object
propertyNames:
type: string
additionalProperties: {}
validFrom:
description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds
from now. Never later than today; an earlier day than the end of the previous answer of the same
kind starts at that end instead.
type: string
minLength: 1
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
required:
- value
responses:
@@ -24209,15 +24573,6 @@ paths:
"403": *a11
"422": *a4
"429": *a5
"501": &a69
description: "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` =
`not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation
already answer as documented. A client reaches this only by ignoring the server feature flag that guards the
operation."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/anamnesis/life-circumstances/ai-sections:
patch:
tags:
@@ -24247,7 +24602,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/anamnesis/life-circumstances/confirm:
post:
tags:
@@ -24271,7 +24625,6 @@ paths:
"401": *a1
"422": *a4
"429": *a5
"501": *a69
/api/anamnesis/audit-c:
post:
tags:
@@ -24301,10 +24654,10 @@ paths:
schema:
$ref: "#/components/schemas/AuditCCreatedEnvelope"
"401": *a1
"403": *a11
"409": *a7
"422": *a4
"429": *a5
"501": *a69
/api/anamnesis/audit-c/{id}:
delete:
tags:
@@ -24325,6 +24678,7 @@ paths:
schema:
$ref: "#/components/schemas/AuditCRemovedEnvelope"
"401": *a1
"403": *a11
"404":
description: "`anamnesis.auditC.notFound`."
content:
@@ -24333,7 +24687,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/dawarich/connect:
post:
tags:
@@ -35715,27 +36068,27 @@ components:
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, anamnesis.fact.conflict,
anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid,
anamnesis.lifeSections.invalid; auth — auth.admin.required, auth.already_authenticated,
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
auth.session.insecure_transport, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited,
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue,
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required,
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required,
auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing,
backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large;
biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown,
dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day —
day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound,
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
@@ -35832,10 +36185,9 @@ components:
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, not_implemented,
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
rate_limited_self, sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json,
vapid_not_configured"
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
private_origin_not_approved, private_origin_not_grantable, rate_limited_self, sync_in_progress,
too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
@@ -41205,6 +41557,10 @@ components:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
- ACTIVITY
- WORK
- HABITS
- GOALS
lifeConfirmedJson:
anyOf:
- type: object
@@ -41338,6 +41694,12 @@ components:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
- TOBACCO_DETAIL
- ACTIVITY_HABIT
- WORK_CONTEXT
- FREE_WEEKDAYS
- HABITS
- HEALTH_GOALS
value:
default: null
anyOf:
@@ -58742,56 +59104,103 @@ components:
type: object
properties:
data:
type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
value:
anyOf:
- type: string
- type: "null"
unreadable:
type: boolean
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
validUntil:
anyOf:
- type: string
anyOf:
- type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
value:
anyOf:
- type: string
- type: "null"
unreadable:
type: boolean
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
supersededByRevisionId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- supersededByRevisionId
- createdAt
additionalProperties: false
validUntil:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
supersededByRevisionId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- supersededByRevisionId
- createdAt
additionalProperties: false
- type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- SHIFT_SCHEDULE
- TOBACCO_DETAIL
- ACTIVITY_HABIT
- WORK_CONTEXT
- FREE_WEEKDAYS
- HABITS
- HEALTH_GOALS
value:
description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema:
1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`."
unreadable:
type: boolean
description: The sealed value could not be opened on this host.
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
validUntil:
type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- createdAt
additionalProperties: false
error:
type: "null"
meta:
@@ -58808,56 +59217,103 @@ components:
type: object
properties:
data:
type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
value:
anyOf:
- type: string
- type: "null"
unreadable:
type: boolean
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
validUntil:
anyOf:
- type: string
anyOf:
- type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
value:
anyOf:
- type: string
- type: "null"
unreadable:
type: boolean
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
supersededByRevisionId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- supersededByRevisionId
- createdAt
additionalProperties: false
validUntil:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
supersededByRevisionId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- supersededByRevisionId
- createdAt
additionalProperties: false
- type: object
properties:
id:
type: string
kind:
type: string
enum:
- SMOKING_STATUS
- SHIFT_SCHEDULE
- TOBACCO_DETAIL
- ACTIVITY_HABIT
- WORK_CONTEXT
- FREE_WEEKDAYS
- HABITS
- HEALTH_GOALS
value:
description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema:
1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`."
unreadable:
type: boolean
description: The sealed value could not be opened on this host.
validFrom:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
validUntil:
type: "null"
provenance:
type: string
enum:
- USER_REPORTED
- USER_CORRECTION
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- kind
- value
- unreadable
- validFrom
- validUntil
- provenance
- createdAt
additionalProperties: false
error:
type: "null"
meta:
@@ -58884,6 +59340,12 @@ components:
- SMOKING_STATUS
- ALCOHOL_PATTERN
- SHIFT_SCHEDULE
- TOBACCO_DETAIL
- ACTIVITY_HABIT
- WORK_CONTEXT
- FREE_WEEKDAYS
- HABITS
- HEALTH_GOALS
removedAt:
type: string
format: date-time
+239 -16
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",
@@ -10911,25 +10924,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",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert"
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
},
"section": {
"measurements": {
+239 -16
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",
@@ -10911,25 +10924,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",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} changed a life event",
"lifeEventDelete": "{name} removed a life event",
"biomarkerMerge": "{name} merged biomarkers",
"medicationIngredientsUpdate": "{name} changed what a supplement contains"
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
"auditCCreate": "{name} answered the AUDIT-C questions",
"auditCDelete": "{name} removed an AUDIT-C entry"
},
"section": {
"measurements": {
+239 -16
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",
@@ -10911,25 +10924,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",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
"biomarkerMerge": "{name} combinó biomarcadores",
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento"
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
},
"section": {
"measurements": {
+239 -16
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",
@@ -10911,25 +10924,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é",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} a modifié un événement de vie",
"lifeEventDelete": "{name} a supprimé un événement de vie",
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément"
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
},
"section": {
"measurements": {
+239 -16
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",
@@ -10911,25 +10924,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",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} ha modificato un evento della vita",
"lifeEventDelete": "{name} ha rimosso un evento della vita",
"biomarkerMerge": "{name} ha unito dei biomarcatori",
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore"
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
},
"section": {
"measurements": {
+239 -16
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": "문제가 생겼어요",
@@ -10911,25 +10924,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": "피운 적 없음",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요"
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
},
"section": {
"measurements": {
+239 -16
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",
@@ -10911,25 +10924,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",
@@ -11109,6 +11108,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": {
@@ -12297,7 +12518,9 @@
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
"biomarkerMerge": "{name} scalił biomarkery",
"medicationIngredientsUpdate": "{name} zmienił skład suplementu"
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
"auditCDelete": "{name} usunął wpis AUDIT-C"
},
"section": {
"measurements": {
+6 -2
View File
@@ -7038,7 +7038,7 @@ model UserHealthProfile {
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
/// @pending-consumer(#v143-B6) v1.43 (#614) — "still true" confirmations of the life
/// v1.43 (#614) — "still true" confirmations of the life
/// circumstance sections: `{ "<section key>": "<ISO instant>" }`. Written by
/// `POST /api/anamnesis/life-circumstances/confirm`, read as
/// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route.
@@ -7134,7 +7134,11 @@ enum HealthProfileFactKind {
/// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object
/// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts`
/// never returns these (a closed decoder in older app builds); they are read
/// through `GET /api/anamnesis/life-circumstances`.
/// through `GET /api/anamnesis/life-circumstances`. Readers: the coach
/// self-context (all six, per released AI section), the doctor report and
/// FHIR (tobacco, activity, work), the chronotype (free weekdays), the
/// environment read (work). `HABITS` and `HEALTH_GOALS` are coach-only by
/// design: diet and goals are context for a conversation, not findings.
TOBACCO_DETAIL
ACTIVITY_HABIT
WORK_CONTEXT
+4 -14
View File
@@ -3,8 +3,8 @@
*
* v1.43 fixed its API contract before the features behind it were built: the
* routes exist, authenticate, gate and validate as documented, and answer
* `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their
* feature lands. That is safe only while nothing reaches them, and nothing
* `501 not_implemented` until their feature lands (the helper was removed
* with the last stub; bring it back from history with the next one). That is safe only while nothing reaches them, and nothing
* should: every client path sits behind a `features` flag that stays `false`.
*
* This list is the debt register. A feature that lands removes its route here
@@ -23,20 +23,10 @@ import { walkSourceFiles } from "./helpers/source-files";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/** Route file (under `src/app/api`) to the operations it still stubs. */
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
"anamnesis/life-circumstances/ai-sections/route.ts": [
"anamnesis.life_circumstances.ai_sections.patch",
],
"anamnesis/life-circumstances/confirm/route.ts": [
"anamnesis.life_circumstances.confirm",
],
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
};
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 5;
const REGISTER_SIZE = 0;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
@@ -403,8 +403,23 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["DeleteButton"],
},
{
file: "components/records/health-profile-facts-manager.tsx",
destroys: "the current value of one effective-dated health-profile fact",
file: "components/records/life-circumstances-manager.tsx",
destroys:
"the current answer of one life-circumstance fact, when a section is saved with it set back to no answer",
recovery: "tombstoned-no-restore",
confirm: [],
unconfirmed: [
{
control:
"Save section with a field set back to no answer — life-circumstances-manager.tsx",
reason:
"Setting a question back to no answer and pressing Save is the deliberate act itself, made field by field inside a form the person is editing, with the old answer visible until they change it. The delete closes the answer's interval rather than erasing it, so the dated history keeps it, and answering again takes one select. A second dialog on top of an explicit form edit would make correcting an answer dearer than giving it.",
},
],
},
{
file: "components/records/audit-c-panel.tsx",
destroys: "one AUDIT-C entry from the alcohol history",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
+25 -3
View File
@@ -893,7 +893,15 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/anamnesis/life-circumstances/route.ts": {
domain: "profile",
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches and confirmations are written through their own owner-only routes; AUDIT-C through the manage-level pair below.",
},
"app/api/anamnesis/audit-c/route.ts": {
domain: "profile",
why: "Recording an AUDIT-C (v1.43) into the record, the alcohol section of the life circumstances. Reached only through its MANAGE arm, the level allergies and family history are written at; the entry is scored on the server against the record's profile.",
},
"app/api/anamnesis/audit-c/[id]/route.ts": {
domain: "profile",
why: "Removing one AUDIT-C entry of the record, scoped `userId: user.id` and `instrument: AUDIT_C` in the guarded update, so a foreign id or a screener id is a 404. Reached only through its MANAGE arm.",
},
"app/api/profile/summary/route.ts": {
domain: "profile",
@@ -1630,6 +1638,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.",
},
"app/api/anamnesis/audit-c/route.ts": {
domain: "profile",
conditions: ["C1"],
why: "Recording an AUDIT-C (v1.43) in the record a manager runs, like an allergy. C1: the 30-an-hour bucket keys on the actor. Arithmetic on three answers, no generation; the trail files the entry id and instrument, never the answers.",
},
"app/api/anamnesis/audit-c/[id]/route.ts": {
domain: "profile",
conditions: [],
why: "Removing an AUDIT-C entry is a soft delete (`deletedAt`), so the row stays to reconstruct and the delete needs no C3; it is the counterpart of the create beside it.",
},
"app/api/allergies/[id]/route.ts": {
domain: "profile",
conditions: ["C3", "C4"],
@@ -2142,8 +2160,12 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.43 -- a supplement's composition joins the record list and the manage
* literal: its PUT replaces the composition every taken dose counts with.
* 260 -> 262.
*
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
* manage literal under `profile`, the level allergies are written at.
* 262 -> 266.
*/
const FROZEN_ENTRY_COUNT = 262;
const FROZEN_ENTRY_COUNT = 266;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2904,7 +2926,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
+11 -1
View File
@@ -508,7 +508,17 @@ const RESULT_SURFACES: ReadonlyArray<{
forbidsMarkers: true,
},
{
path: "src/components/records/health-profile-facts-manager.tsx",
path: "src/components/records/life-circumstances-manager.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
{
path: "src/components/records/audit-c-panel.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
{
path: "src/components/records/free-text-take-over.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
+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;
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
+42 -10
View File
@@ -1,21 +1,53 @@
/**
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
* Soft delete (`deletedAt`); answers `{ id, removedAt }`, 404
* `anamnesis.auditC.notFound` for an id that is not a live AUDIT-C entry of
* the record. Authority `requireRecordAuth("manage", "profile")`, like the
* create.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { invalidateHealthProfileFactConsumers } from "@/lib/profile/health-facts";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
await requireAuth();
await params;
return notImplemented("anamnesis.audit_c.delete");
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// Soft delete, scoped to a live AUDIT-C entry of the record: a foreign
// id, a screener's id or an entry already removed is the same 404.
const now = new Date();
const result = await prisma.mentalHealthAssessment.updateMany({
where: { id, userId: user.id, instrument: "AUDIT_C", deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
if (result.count !== 1) {
return apiError("AUDIT-C entry not found", 404, {
errorCode: "anamnesis.auditC.notFound",
});
}
await invalidateHealthProfileFactConsumers(user.id, prisma);
const removed = { id, removedAt: now.toISOString() };
await auditLog("anamnesis.audit_c.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { assessmentId: removed.id, instrument: "AUDIT_C" },
});
annotate({
action: {
name: "anamnesis.audit_c.delete",
entity_type: "mental_health_assessment",
entity_id: removed.id,
},
});
return apiSuccess(removed);
},
);
+44 -9
View File
@@ -6,26 +6,36 @@
* the entry's language and the profile's sex (null without one); a replay
* with the same `externalId` answers 200 with the same result. Stored beside
* the mental-health screeners but never listed by
* `/api/mental-health/assessments`. At most 30 an hour.
* `/api/mental-health/assessments`. At most 30 an hour, counted against the
* person acting.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
* Authority: `requireRecordAuth("manage", "profile")`, the level allergies and
* family history are written at; the entry lands in the record and the trail
* names the manager as actor.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { createAuditC } from "@/lib/profile/life-circumstances";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
async function postAuditC(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { user, actor } = await requireRecordAuth("manage", "profile");
const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000);
const rl = await checkRateLimit(`audit-c:${actor.id}`, 30, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many entries, try again later", 429, {
errorCode: "anamnesis.auditC.rateLimited",
@@ -43,5 +53,30 @@ async function postAuditC(request: NextRequest): Promise<Response> {
});
}
return notImplemented("anamnesis.audit_c.create");
const input = parsed.data;
const { result, replayed } = await createAuditC(user.id, {
items: input.items,
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
tz: input.tz,
locale: input.locale ?? user.locale ?? "en",
source: input.source,
externalId: input.externalId,
});
annotate({
action: {
name: replayed ? "anamnesis.audit_c.replay" : "anamnesis.audit_c.create",
entity_type: "mental_health_assessment",
entity_id: result.id,
},
});
if (replayed) return apiSuccess({ result }, 200);
// The id and the instrument only; the answers and the sum stay out of the trail.
await auditLog("anamnesis.audit_c.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { assessmentId: result.id, instrument: "AUDIT_C" },
});
return apiSuccess({ result }, 201);
}
+45 -12
View File
@@ -13,16 +13,28 @@ import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
correctHealthProfileFact,
correctLifeFact,
removeHealthProfileFact,
} from "@/lib/profile/health-facts";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import {
healthProfileFactCorrectionSchema,
validateCorrectedFactValue,
type HealthProfileFactKind,
} from "@/lib/validations/health-profile-facts";
import {
factCorrectionSchema,
isLifeFactKind,
parseLifeFactValue,
} from "@/lib/validations/life-circumstances";
type RouteParams = { params: Promise<{ id: string }> };
function invalidValue(): Response {
return apiError("Value is not valid for this fact kind", 422, {
errorCode: "anamnesis.fact.invalidValue",
});
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -32,7 +44,7 @@ export const PATCH = apiHandler(
});
if (error) return error;
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
const parsed = factCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const current = await prisma.healthProfileFactRevision.findFirst({
@@ -46,17 +58,38 @@ export const PATCH = apiHandler(
});
if (!current) return apiError("Current profile fact not found", 404);
const value = validateCorrectedFactValue(
current.kind as HealthProfileFactKind,
parsed.data.value,
);
if (!value) {
return apiError("Value is not valid for this fact kind", 422, {
errorCode: "anamnesis.fact.invalidValue",
});
// The value's form follows the stored kind: a string from the closed set
// for the three original kinds, an object with `schema: 1` for the life
// circumstances (v1.43, #614).
const now = new Date();
const validFrom = parsed.data.validFrom
? dateOnlyAtNoonUtc(parsed.data.validFrom)
: undefined;
let corrected: { id: string; kind: string } | null;
if (isLifeFactKind(current.kind)) {
const value =
typeof parsed.data.value === "string"
? null
: parseLifeFactValue(current.kind, parsed.data.value);
if (!value) return invalidValue();
corrected = await correctLifeFact(user.id, id, value, now, validFrom);
} else {
const value =
typeof parsed.data.value === "string"
? validateCorrectedFactValue(
current.kind as HealthProfileFactKind,
parsed.data.value,
)
: null;
if (!value) return invalidValue();
corrected = await correctHealthProfileFact(
user.id,
id,
value,
now,
validFrom,
);
}
const corrected = await correctHealthProfileFact(user.id, id, value);
if (!corrected) {
return apiError("Profile fact changed since it was loaded", 409, {
errorCode: "anamnesis.fact.conflict",
@@ -106,6 +106,8 @@ describe("anamnesis facts API", () => {
"user-1",
"SMOKING_STATUS",
"FORMER",
expect.any(Date),
undefined,
);
expect(mocks.audit).toHaveBeenCalledOnce();
});
@@ -135,7 +137,13 @@ describe("anamnesis facts API", () => {
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
}),
);
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
expect(mocks.correct).toHaveBeenCalledWith(
"user-1",
"fact-1",
"NEVER",
expect.any(Date),
undefined,
);
expect(mocks.audit).toHaveBeenCalledOnce();
});
+33 -7
View File
@@ -14,10 +14,18 @@ import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import {
createHealthProfileFact,
createLifeFact,
readHealthProfileFacts,
} from "@/lib/profile/health-facts";
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import { factWriteSchema } from "@/lib/validations/life-circumstances";
/**
* `GET` lists the three closed kinds only (`SMOKING_STATUS`, `ALCOHOL_PATTERN`,
* `SHIFT_SCHEDULE`): older app builds decode this list closed. `POST` also
* takes the six life-circumstance kinds (v1.43, #614), which are read back
* through `GET /api/anamnesis/life-circumstances`.
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "profile");
const payload = await readHealthProfileFacts(user.id);
@@ -38,15 +46,33 @@ async function postFact(request: NextRequest): Promise<Response> {
});
if (error) return error;
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
const parsed = factWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const now = new Date();
const validFrom = parsed.data.validFrom
? dateOnlyAtNoonUtc(parsed.data.validFrom)
: undefined;
try {
const fact = await createHealthProfileFact(
user.id,
parsed.data.kind,
parsed.data.value,
);
const input = parsed.data;
const fact =
input.kind === "SMOKING_STATUS" ||
input.kind === "ALCOHOL_PATTERN" ||
input.kind === "SHIFT_SCHEDULE"
? await createHealthProfileFact(
user.id,
input.kind,
input.value,
now,
validFrom,
)
: await createLifeFact(
user.id,
input.kind,
input.value,
now,
validFrom,
);
await auditLog("anamnesis.fact.create", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -6,17 +6,26 @@
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
* Own account only.
* Own account only: which of a person's answers a model may read is that
* person's decision.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
export const PATCH = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
@@ -29,5 +38,30 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
const outcome = await updateLifeAiSections(
user.id,
parsed.data.sections,
parsed.data.baseUpdatedAt ? new Date(parsed.data.baseUpdatedAt) : undefined,
);
if (outcome.conflict) {
annotate({ action: { name: "anamnesis.life_sections.conflict" } });
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
// Section names and switch states only; nothing about the answers.
await auditLog("anamnesis.life_sections.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { sections: parsed.data.sections },
});
annotate({
action: { name: "anamnesis.life_sections.update" },
meta: { changed: Object.keys(parsed.data.sections).length },
});
return apiSuccess({
aiSections: outcome.aiSections,
updatedAt: outcome.updatedAt,
});
});
@@ -1,17 +1,20 @@
/**
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
* section (#614). `{ section }`; stores the confirmation instant only, never a
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only,
* like the answers' AI switches: it vouches for the person's own account of
* their life, which a delegate cannot do for them.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { confirmLifeSection } from "@/lib/profile/life-circumstances";
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
@@ -24,5 +27,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("anamnesis.life_circumstances.confirm");
const lastConfirmedAt = await confirmLifeSection(
user.id,
parsed.data.section,
);
annotate({
action: { name: "anamnesis.life_section.confirm" },
meta: { section: parsed.data.section },
});
return apiSuccess({ section: parsed.data.section, lastConfirmedAt });
});
@@ -9,11 +9,24 @@
* read it under the `profile` grant, like the facts.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { buildLifeCircumstances } from "@/lib/profile/life-circumstances";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented("anamnesis.life_circumstances.get");
const { user } = await requireRecordAuth("read", "profile");
const payload = await buildLifeCircumstances(user.id);
annotate({
action: { name: "anamnesis.life_circumstances.get" },
meta: {
fact_count: payload.sections.reduce(
(sum, section) => sum + section.facts.length,
0,
),
audit_c_count: payload.auditC.history.length,
},
});
return apiSuccess(payload);
});
+1 -1
View File
@@ -90,7 +90,7 @@ const SERVER_FEATURES = {
cumulativeHourBuckets: true,
syncTriggerBackfill: true,
biomarkerMerge: true,
lifeCircumstances: false,
lifeCircumstances: true,
} as const;
export const GET = apiHandler(async () => {
+31 -12
View File
@@ -64,7 +64,9 @@ import {
} from "@/lib/validations/about-me";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
type HealthProfileAiSection,
HEALTH_PROFILE_AI_SECTIONS,
aboutMeAiSections,
type StoredHealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
const PUT_RATE_LIMIT = 30;
@@ -101,9 +103,13 @@ export const GET = apiHandler(async () => {
}),
getAiCapability("aboutMeQuestions"),
]);
const aiIncludedSections =
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS;
// The eight sections this wire has always carried. The life-circumstance
// sections (v1.43) live in the same column but are switched and read
// through `/api/anamnesis/life-circumstances`; older app builds decode this
// list closed.
const aiIncludedSections = aboutMeAiSections(
row?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
// A stored set may be model-written. While the capability is unavailable
// an outstanding set is swapped for the deterministic hints; an answered
// (empty) set stays empty.
@@ -201,7 +207,7 @@ export const PUT = apiHandler(async (req: Request) => {
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
aiIncludedSections?: HealthProfileAiSection[];
aiIncludedSections?: StoredHealthProfileAiSection[];
} = {};
const aboutMePayload = encryptOptional(parsed.data.aboutMe);
if (aboutMePayload !== undefined) {
@@ -219,9 +225,6 @@ export const PUT = apiHandler(async (req: Request) => {
if (coachFocusPayload !== undefined) {
update.coachFocusEncrypted = coachFocusPayload;
}
if (parsed.data.aiIncludedSections !== undefined) {
update.aiIncludedSections = parsed.data.aiIncludedSections;
}
const fieldLengths: Record<string, number> = {
...(text !== undefined ? { aboutMe: text.length } : {}),
...(parsed.data.conditions !== undefined
@@ -240,6 +243,23 @@ export const PUT = apiHandler(async (req: Request) => {
// commit while an hour-fresh advisor payload still references excluded
// profile content.
const persistence = await prisma.$transaction(async (tx) => {
// A PUT here replaces the eight sections it lists and leaves the
// life-circumstance switches (v1.43) exactly as stored.
if (parsed.data.aiIncludedSections !== undefined) {
const stored = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aiIncludedSections: true },
});
update.aiIncludedSections = [
...parsed.data.aiIncludedSections,
...(stored?.aiIncludedSections ?? []).filter(
(section) =>
!(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(
section,
),
),
];
}
let updatedAtIso: string;
let savedProfileUpdatedAt: Date | null = null;
@@ -334,10 +354,9 @@ export const PUT = apiHandler(async (req: Request) => {
select: { aiIncludedSections: true },
}),
]);
const aiIncludedSections = (controlRow?.aiIncludedSections as
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
];
const aiIncludedSections = aboutMeAiSections(
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
const aiContext = filterSelfContextForAi(ctx, aiIncludedSections);
const aiContextIsEmpty =
aiContext.aboutMe === null &&
+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[] = [];
@@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
@@ -30,6 +30,7 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -122,7 +123,12 @@ export const GET = apiHandler(
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
// a soft-deleted row 404s like it does on the list.
const row = await prisma.mentalHealthAssessment.findFirst({
where: { id, deletedAt: null },
// Screeners only; an AUDIT-C entry (v1.43) is a 404 here.
where: {
id,
deletedAt: null,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: {
id: true,
userId: true,
@@ -250,7 +250,12 @@ describe("POST /api/mental-health/assessments", () => {
expect(body.data.assessment.id).toBe("mha_dup");
expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", externalId: "outbox-7f3c" },
// Screeners only: an AUDIT-C entry (v1.43) is never a dedup match.
where: {
userId: "user-1",
externalId: "outbox-7f3c",
instrument: { in: ["PHQ9", "GAD7", "WHO5", "SCI"] },
},
}),
);
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
+14 -3
View File
@@ -36,6 +36,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
SCREENER_INSTRUMENTS,
createAssessmentSchema,
listAssessmentsSchema,
} from "@/lib/validations/mental-health";
@@ -121,7 +122,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: {
userId: user.id,
deletedAt: null,
...(instrument ? { instrument } : {}),
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
@@ -245,7 +248,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// per user, not globally.
if (externalId) {
const existing = await prisma.mentalHealthAssessment.findFirst({
where: { userId: user.id, externalId },
where: {
userId: user.id,
externalId,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: {
id: true,
instrument: true,
@@ -332,7 +339,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
(err as { code?: unknown }).code === "P2002"
) {
const winner = await prisma.mentalHealthAssessment.findFirst({
where: { userId: user.id, externalId },
where: {
userId: user.id,
externalId,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: assessmentSelect,
});
if (winner) {
+7 -1
View File
@@ -280,7 +280,13 @@ export function ClinicianView({
{/* ── the fenced tier, as the owner chose it ───────────── */}
<FamilyHistorySection t={t} report={report} scope={scope} />
<AnamnesisSection t={t} report={report} scope={scope} />
<AnamnesisSection
t={t}
report={report}
scope={scope}
fmtNum={fmtNum}
fmtDate={fmtDate}
/>
<MoodSection t={t} report={report} scope={scope} fmtNum={fmtNum} />
<CycleSection
t={t}
@@ -13,6 +13,7 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import { Fragment } from "react";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
@@ -473,10 +474,14 @@ export function AnamnesisSection({
t,
report,
scope,
fmtNum,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtNum: (n: number) => string;
fmtDate: (iso: string) => string;
}) {
const anamnesis = report.anamnesis ?? null;
@@ -518,6 +523,11 @@ export function AnamnesisSection({
label={t("doctorReport.anamnesisShiftSchedule")}
value={factValue("SHIFT_SCHEDULE", anamnesis?.shiftSchedule ?? null)}
/>
{lifeReportRows(anamnesis?.life, t, fmtNum, fmtDate).map(
([label, value]) => (
<StatRow key={label} label={label} value={value} />
),
)}
</LeafSection>
);
}
@@ -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>
);
}
@@ -35,8 +35,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => null,
}));
vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: ({
@@ -18,8 +18,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => <div data-slot="conditions" />,
}));
vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
@@ -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>
);
}
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
allergyFindMany: vi.fn(),
familyFindMany: vi.fn(),
factFindMany: vi.fn(),
auditFindFirst: vi.fn(),
decrypt: vi.fn(),
}));
@@ -16,6 +17,7 @@ vi.mock("@/lib/db", () => ({
allergy: { findMany: mocks.allergyFindMany },
familyHistoryEntry: { findMany: mocks.familyFindMany },
healthProfileFactRevision: { findMany: mocks.factFindMany },
mentalHealthAssessment: { findFirst: mocks.auditFindFirst },
},
}));
@@ -44,6 +46,7 @@ beforeEach(() => {
mocks.allergyFindMany.mockResolvedValue([]);
mocks.familyFindMany.mockResolvedValue([]);
mocks.factFindMany.mockResolvedValue([]);
mocks.auditFindFirst.mockResolvedValue(null);
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
if (bytes[0] === 1) return secret;
if (bytes[0] === 2) return "Asthma";
@@ -166,4 +169,89 @@ describe("profile section access in AI prompt paths", () => {
expect(mocks.factFindMany).toHaveBeenCalledOnce();
expect(text).toContain("Penicillin");
});
describe("life circumstances (v1.43)", () => {
const habitsBytes = new Uint8Array([20]);
const goalsBytes = new Uint8Array([21]);
const validFrom = new Date("2026-03-01T00:00:00.000Z");
beforeEach(() => {
// Answer with exactly the kinds the query asked for, the way the
// database would, so a kind left out of the query is never returned.
mocks.factFindMany.mockImplementation(
({ where }: { where: { kind: { in: string[] } } }) =>
Promise.resolve(
[
{ kind: "HABITS", valueEncrypted: habitsBytes, validFrom },
{ kind: "HEALTH_GOALS", valueEncrypted: goalsBytes, validFrom },
].filter((row) => where.kind.in.includes(row.kind)),
),
);
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
if (bytes[0] === 20) {
return JSON.stringify({
schema: 1,
napRegularly: null,
snoringReported: true,
dietPattern: "vegan",
dietSpecifics: [],
lateMealsPerWeek: null,
fruitVegPortionsPerDay: null,
caffeineDrinksPerDay: 4,
latestCaffeine: "after_18",
});
}
if (bytes[0] === 21) {
return JSON.stringify({ schema: 1, goals: ["sleep"] });
}
return "unknown";
});
});
it("never decrypts a section without its switch", async () => {
mockIncluded(["GOALS"]);
const text = await getSelfContextTextForUser("user-1", "en");
const decrypted = mocks.decrypt.mock.calls.map(([bytes]) => bytes);
expect(decrypted).not.toContain(habitsBytes);
expect(decrypted).toContain(goalsBytes);
const query = mocks.factFindMany.mock.calls[0][0] as {
where: { kind: { in: string[] } };
};
expect(query.where.kind.in).not.toContain("HABITS");
expect(text).toContain("Health goals: sleep");
expect(text).toContain("self-report, valid since 2026-03");
expect(text).not.toContain("vegan");
});
it("adds the section once it is switched on", async () => {
mockIncluded(["HABITS"]);
const text = await getSelfContextTextForUser("user-1", "en");
expect(mocks.decrypt.mock.calls.map(([bytes]) => bytes)).toContain(
habitsBytes,
);
expect(text).toContain("Diet pattern: vegan");
expect(text).toContain("Latest caffeine: after 18");
});
it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => {
mocks.auditFindFirst.mockResolvedValue({
totalScore: 5,
takenAt: new Date("2026-10-01T09:00:00.000Z"),
locale: "de",
});
mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" });
mockIncluded(["ALCOHOL_PATTERN"]);
const text = await getSelfContextTextForUser("user-1", "de");
expect(text).toContain(
"AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht",
);
mocks.auditFindFirst.mockClear();
mockIncluded(["GOALS"]);
await getSelfContextTextForUser("user-1", "de");
expect(mocks.auditFindFirst).not.toHaveBeenCalled();
});
});
});
+368 -63
View File
@@ -27,6 +27,7 @@
* Server-only — reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import { userDayKey } from "@/lib/tz/format";
import { getEvent } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -40,7 +41,24 @@ import {
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import {
decryptHealthProfileFactValue,
decryptLifeFactValue,
} from "@/lib/profile/health-facts";
import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
import { auditCCutoff } from "@/lib/mental-health/audit-c";
import {
LIFE_FACT_KINDS,
isLifeFactKind,
type ActivityHabitValue,
type FreeWeekdaysValue,
type HabitsValue,
type HealthGoalsValue,
type LifeFactKind,
type LifeFactValue,
type TobaccoDetailValue,
type WorkContextValue,
} from "@/lib/validations/life-circumstances";
export interface SelfContext {
aboutMe: string | null;
@@ -397,6 +415,260 @@ export function composeStructuredHealthFactsText(
return lines.length > 0 ? lines.join("\n") : null;
}
// ── Life circumstances (v1.43, #614) ─────────────────────────────────────────
type LifeLang = "de" | "en";
const LIFE_LABELS: Record<LifeLang, Record<string, string>> = {
de: {
selfReport: "Selbstauskunft, gilt seit",
products: "Tabakprodukte",
cigarettes: "Zigaretten pro Tag",
startYear: "Rauchbeginn",
quit: "Rauchstopp",
secondhand: "Passivrauch",
activity: "Mäßige bis anstrengende Bewegung",
activityValue: "{days} Tage pro Woche, je {minutes} Minuten",
sitting: "Sitzen an Werktagen (Stunden)",
situation: "Arbeitssituation",
nature: "Art der Arbeit",
night: "Nachtarbeit",
exposures: "Belastungen bei der Arbeit",
freeDays: "Freie Wochentage",
nap: "Regelmäßiger Mittagsschlaf",
snoring: "Schnarchen laut anderen",
diet: "Ernährungsweise",
dietSpecifics: "Besonderheiten",
lateMeals: "Mahlzeiten nach 21 Uhr pro Woche",
fruitVeg: "Portionen Obst und Gemüse pro Tag",
caffeine: "Koffeinhaltige Getränke pro Tag",
latestCaffeine: "Spätestes Koffein",
goals: "Gesundheitsziele",
auditC: "AUDIT-C (Screening, keine Diagnose)",
auditCValue: "{score} von 12 am {date}",
above: "Screening-Grenzwert {cutoff} erreicht",
below: "unter dem Screening-Grenzwert {cutoff}",
yes: "ja",
no: "nein",
},
en: {
selfReport: "self-report, valid since",
products: "Tobacco products",
cigarettes: "Cigarettes per day",
startYear: "Started smoking",
quit: "Stopped smoking",
secondhand: "Secondhand smoke",
activity: "Moderate to strenuous activity",
activityValue: "{days} days a week, {minutes} minutes each",
sitting: "Sitting on a workday (hours)",
situation: "Work situation",
nature: "Physical nature of work",
night: "Night work",
exposures: "Exposures at work",
freeDays: "Free weekdays",
nap: "Regular nap",
snoring: "Snoring reported by others",
diet: "Diet pattern",
dietSpecifics: "Specifics",
lateMeals: "Meals after 9 pm per week",
fruitVeg: "Fruit and vegetable portions per day",
caffeine: "Caffeinated drinks per day",
latestCaffeine: "Latest caffeine",
goals: "Health goals",
auditC: "AUDIT-C (screening, not a diagnosis)",
auditCValue: "{score} of 12 on {date}",
above: "at or above the screening cut-off of {cutoff}",
below: "below the screening cut-off of {cutoff}",
yes: "yes",
no: "no",
},
};
const WEEKDAY_NAMES: Record<LifeLang, readonly string[]> = {
de: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
en: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
};
function fill(
template: string,
params: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
String(params[key] ?? ""),
);
}
/** One current life-circumstance revision as the prompt block reads it. */
export interface LifeFactForPrompt {
kind: LifeFactKind;
value: LifeFactValue;
validFrom: Date;
}
/** The newest AUDIT-C entry as the prompt block reads it: the sum and the cut-off verdict only. */
export interface AuditCForPrompt {
totalScore: number;
takenAt: Date;
/** The zone the entry was taken in, for its calendar day. */
tz: string;
cutoff: number | null;
aboveCutoff: boolean | null;
}
/**
* Compose the life-circumstance lines of the self-context block: each line
* says it is a self-report and since when it holds, the enum values stay in
* their wire form where a translation would add nothing (`fumes_solvents`
* reads fine to a model), and AUDIT-C is a sum with a screening verdict,
* never a label for the person. Null when there is nothing to say.
*/
export function composeLifeCircumstancesText(
facts: readonly LifeFactForPrompt[],
auditC: AuditCForPrompt | null,
locale: string,
): string | null {
const lang: LifeLang = locale === "de" ? "de" : "en";
const L = LIFE_LABELS[lang];
const yesNo = (v: boolean) => (v ? L.yes : L.no);
const since = (d: Date) => `(${L.selfReport} ${d.toISOString().slice(0, 7)})`;
const words = (list: readonly string[]) =>
list.map((w) => w.replace(/_/g, " ")).join(", ");
const lines: string[] = [];
for (const fact of facts) {
const parts: string[] = [];
switch (fact.kind) {
case "TOBACCO_DETAIL": {
const v = fact.value as TobaccoDetailValue;
if (v.products.length > 0)
parts.push(`${L.products}: ${words(v.products)}`);
if (v.cigarettesPerDay !== null)
parts.push(`${L.cigarettes}: ${v.cigarettesPerDay}`);
if (v.startYear !== null) parts.push(`${L.startYear}: ${v.startYear}`);
if (v.quitMonth !== null) parts.push(`${L.quit}: ${v.quitMonth}`);
if (v.secondhandSmoke !== null)
parts.push(`${L.secondhand}: ${yesNo(v.secondhandSmoke)}`);
break;
}
case "ACTIVITY_HABIT": {
const v = fact.value as ActivityHabitValue;
if (
v.moderateDaysPerWeek !== null &&
v.moderateMinutesPerDay !== null
) {
parts.push(
`${L.activity}: ${fill(L.activityValue, {
days: v.moderateDaysPerWeek,
minutes: v.moderateMinutesPerDay,
})}`,
);
}
if (v.sittingHoursWorkday !== null)
parts.push(`${L.sitting}: ${v.sittingHoursWorkday}`);
break;
}
case "WORK_CONTEXT": {
const v = fact.value as WorkContextValue;
if (v.situation !== null)
parts.push(`${L.situation}: ${words([v.situation])}`);
if (v.physicalNature !== null)
parts.push(`${L.nature}: ${words([v.physicalNature])}`);
if (v.nightWork !== null)
parts.push(`${L.night}: ${yesNo(v.nightWork)}`);
if (v.exposures.length > 0)
parts.push(`${L.exposures}: ${words(v.exposures)}`);
break;
}
case "FREE_WEEKDAYS": {
const v = fact.value as FreeWeekdaysValue;
parts.push(
`${L.freeDays}: ${[...v.days]
.sort((a, b) => a - b)
.map((d) => WEEKDAY_NAMES[lang][d - 1])
.join(", ")}`,
);
break;
}
case "HABITS": {
const v = fact.value as HabitsValue;
if (v.napRegularly !== null)
parts.push(`${L.nap}: ${yesNo(v.napRegularly)}`);
if (v.snoringReported !== null)
parts.push(`${L.snoring}: ${yesNo(v.snoringReported)}`);
if (v.dietPattern !== null)
parts.push(`${L.diet}: ${words([v.dietPattern])}`);
if (v.dietSpecifics.length > 0)
parts.push(`${L.dietSpecifics}: ${words(v.dietSpecifics)}`);
if (v.lateMealsPerWeek !== null)
parts.push(`${L.lateMeals}: ${v.lateMealsPerWeek}`);
if (v.fruitVegPortionsPerDay !== null)
parts.push(`${L.fruitVeg}: ${v.fruitVegPortionsPerDay}`);
if (v.caffeineDrinksPerDay !== null)
parts.push(`${L.caffeine}: ${v.caffeineDrinksPerDay}`);
if (v.latestCaffeine !== null)
parts.push(`${L.latestCaffeine}: ${words([v.latestCaffeine])}`);
break;
}
case "HEALTH_GOALS": {
const v = fact.value as HealthGoalsValue;
if (v.goals.length > 0) parts.push(`${L.goals}: ${words(v.goals)}`);
break;
}
}
if (parts.length > 0)
lines.push(`${parts.join("; ")} ${since(fact.validFrom)}`);
}
if (auditC) {
const verdict =
auditC.cutoff === null || auditC.aboveCutoff === null
? ""
: `, ${fill(auditC.aboveCutoff ? L.above : L.below, { cutoff: auditC.cutoff })}`;
lines.push(
`${L.auditC}: ${fill(L.auditCValue, {
score: auditC.totalScore,
date: userDayKey(auditC.takenAt, auditC.tz),
})}${verdict}`,
);
}
return lines.length > 0 ? lines.join("\n") : null;
}
/**
* The newest AUDIT-C entry for a prompt: only when the alcohol section is
* released, and without decrypting anything (the sum is stored in the clear,
* like the screeners'). The profile sex for the cut-off is read only when
* there is an entry to judge.
*/
async function loadAuditCForPrompt(
userId: string,
included: ReadonlySet<string>,
): Promise<AuditCForPrompt | null> {
if (!included.has("ALCOHOL_PATTERN")) return null;
const audit = await prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { totalScore: true, takenAt: true, locale: true, tz: true },
});
if (!audit) return null;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { gender: true, locale: true, timezone: true },
});
const cutoff = auditCCutoff(
audit.locale,
user?.gender ?? null,
user?.locale ?? "en",
);
return {
totalScore: audit.totalScore,
takenAt: audit.takenAt,
tz: audit.tz ?? user?.timezone ?? "UTC",
cutoff: cutoff?.value ?? null,
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
};
}
/**
* Compose the merged self-context text the prompt block quotes: profile
* facts first (age/gender from the User row — the single source, never
@@ -486,51 +758,64 @@ export async function getSelfContextTextForUser(
(controlRow?.aiIncludedSections ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as HealthProfileAiSection[],
);
const includedFactKinds = (
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const
).filter((kind) => included.has(kind));
const includedStored: ReadonlySet<string> = new Set<string>(
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
// One read for every fact kind a released section covers: the three
// closed kinds and the life-circumstance kinds (v1.43). A kind whose
// section is not released is never read, so it is never decrypted.
const includedFactKinds = [
...(["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const),
...LIFE_FACT_KINDS,
].filter((kind) =>
includedStored.has(
isLifeFactKind(kind) ? LIFE_KIND_AI_SECTION[kind] : kind,
),
);
const [ctx, user, allergyRows, familyRows, factRows] = await Promise.all([
getSelfContextForUser(userId, prisma, included),
included.has("ABOUT_ME")
? prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true },
})
: Promise.resolve(null),
included.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
})
: Promise.resolve([]),
included.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
})
: Promise.resolve([]),
includedFactKinds.length > 0
? prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: includedFactKinds },
validUntil: null,
supersededByRevisionId: null,
},
orderBy: { kind: "asc" },
select: { kind: true, valueEncrypted: true },
})
: Promise.resolve([]),
]);
const [ctx, user, allergyRows, familyRows, factRows, auditC] =
await Promise.all([
getSelfContextForUser(userId, prisma, included),
included.has("ABOUT_ME")
? prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true },
})
: Promise.resolve(null),
included.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
})
: Promise.resolve([]),
included.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
})
: Promise.resolve([]),
includedFactKinds.length > 0
? prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: includedFactKinds },
validUntil: null,
supersededByRevisionId: null,
},
orderBy: { kind: "asc" },
select: { kind: true, valueEncrypted: true, validFrom: true },
})
: Promise.resolve([]),
loadAuditCForPrompt(userId, includedStored),
]);
const selfText = composeSelfContextText(
ctx,
@@ -552,24 +837,36 @@ export async function getSelfContextTextForUser(
locale,
);
const factsText = composeStructuredHealthFactsText(
factRows.flatMap((row) => {
const decrypted = decryptHealthProfileFactValue(
row.kind as HealthProfileFactKind,
row.valueEncrypted,
);
return decrypted.value
? [
{
kind: row.kind as HealthProfileFactKind,
value: decrypted.value,
},
]
: [];
}),
factRows
.filter((row) => !isLifeFactKind(row.kind))
.flatMap((row) => {
const decrypted = decryptHealthProfileFactValue(
row.kind as HealthProfileFactKind,
row.valueEncrypted,
);
return decrypted.value
? [
{
kind: row.kind as HealthProfileFactKind,
value: decrypted.value,
},
]
: [];
}),
locale,
);
if (!selfText && !recordsText && !factsText) return null;
return [selfText, recordsText, factsText].filter(Boolean).join("\n");
const lifeFacts = factRows.flatMap((row) => {
if (!isLifeFactKind(row.kind)) return [];
const opened = decryptLifeFactValue(row.kind, row.valueEncrypted);
return opened.value
? [{ kind: row.kind, value: opened.value, validFrom: row.validFrom }]
: [];
});
const lifeText = composeLifeCircumstancesText(lifeFacts, auditC, locale);
if (!selfText && !recordsText && !factsText && !lifeText) return null;
return [selfText, recordsText, factsText, lifeText]
.filter(Boolean)
.join("\n");
} catch (err) {
getEvent()?.addWarning(
`coach self-context text load failed: ${
@@ -683,7 +980,11 @@ INTERPRETATIONSREGELN:
- Das Alkoholkonsum-Muster nur als Kontext nutzen, ohne Menge oder Wirkung zu
erfinden.
- Feste oder wechselnde Schichtarbeit bei der Schlafzeit-Einordnung
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.`;
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.
- Ein AUDIT-C-Wert ist ein Screening-Ergebnis: nie als Diagnose oder
Alkoholproblem bezeichnen.
- Selbstauskünfte zu Bewegung, Schlaf und Ernährung ergänzen die Messdaten und
ersetzen sie nie; bei Widerspruch gelten die Messdaten.`;
}
return `
@@ -702,5 +1003,9 @@ INTERPRETATION RULES:
smoking cessation.
- Use an alcohol pattern as context only, without inventing amount or effect.
- Account for fixed or rotating shifts when interpreting sleep timing; do not
call irregular timing a problem on that fact alone.`;
call irregular timing a problem on that fact alone.
- An AUDIT-C score is a screening result: never call it a diagnosis or an
alcohol problem.
- Self-reported activity, sleep and diet habits add to the measurements and
never replace them; on a conflict the measurements win.`;
}
@@ -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,9 @@ 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 { getEvent } from "@/lib/logging/context";
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 +106,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 +165,63 @@ 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> {
try {
return await readWorkContext(userId);
} catch (error) {
// Context, not data: a failed read leaves the note out, never the days.
getEvent()?.addWarning(
`environment work context read failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
}
async function readWorkContext(
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 +385,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 +403,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",
+3
View File
@@ -29,6 +29,7 @@ import { readLocalDaysWithReadings } from "@/lib/day/daily-stats";
import { loadNotableRange } from "@/lib/day/notable";
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
import { prisma } from "@/lib/db";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { effectiveMoodTz } from "@/lib/mood/date-key";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -341,6 +342,8 @@ const INDEX_READERS: Partial<
where: {
userId: frame.userId,
deletedAt: null,
// AUDIT-C (v1.43) is not a screener and marks no day.
instrument: { in: [...SCREENER_INSTRUMENTS] },
takenAt: {
gte: new Date(frame.start.getTime() - 14 * 3_600_000),
lt: new Date(frame.end.getTime() + 14 * 3_600_000),
+3
View File
@@ -29,6 +29,7 @@ import type {
DaySectionKey,
} from "@/lib/day/contract";
import { prisma } from "@/lib/db";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
import { listTargetsBySource } from "@/lib/links/link-service";
import { getEvent } from "@/lib/logging/context";
import { lastDayCovered } from "@/lib/life-events/dates";
@@ -851,6 +852,8 @@ async function assessmentsPart(frame: DayFrame): Promise<SectionPart> {
where: {
userId: frame.userId,
deletedAt: null,
// AUDIT-C (v1.43) lives in the life circumstances, not the day's screeners.
instrument: { in: [...SCREENER_INSTRUMENTS] },
takenAt: zoneSafeWindow(frame),
},
select: {
@@ -1,3 +1,4 @@
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
@@ -603,6 +604,12 @@ export function buildClinicalRecordsNotesSection(
t("doctorReport.anamnesisShiftSchedule"),
factValue("SHIFT_SCHEDULE", data.anamnesis.shiftSchedule),
],
...lifeReportRows(
data.anamnesis.life,
t,
(value) => num(value, 1),
fmtDate,
),
];
autoTable(doc, {
+8
View File
@@ -10,6 +10,7 @@
* `doctor-report-data.ts`.
*/
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
@@ -463,6 +464,13 @@ export interface DoctorReportData {
unreadableFacts: Array<
"SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE"
>;
/**
* v1.43 (#614) — the clinical part of the life circumstances: pack-years,
* the newest AUDIT-C (a screening result with its cut-off and source),
* activity and work. Optional so a report built before v1.43 still
* renders.
*/
life?: LifeClinicalSummary;
} | null;
/**
* Emergency ("Notfalldaten") facts for the page-one section. Present only
@@ -54,6 +54,10 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
calls.push(name);
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
},
findFirst: () => {
calls.push(name);
return Promise.resolve(rows[`${name}First`]?.[0] ?? null);
},
});
return {
calls,
@@ -463,6 +467,8 @@ describe("zero read for unchosen leaves", () => {
["ANAMNESIS", "userHealthProfile"],
["EMERGENCY", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
// v1.43 — the newest AUDIT-C entry rides the anamnesis leaf.
["ANAMNESIS", "mentalHealthAssessment"],
];
// ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and
+11 -4
View File
@@ -23,13 +23,15 @@ import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import { loadLifeClinicalSummary } from "@/lib/profile/life-circumstances";
import {
emergencyProfileHasContent,
readEmergencyProfile,
} from "@/lib/profile/emergency-profile";
import type {
HealthProfileFactKind,
HealthProfileFactValue,
import {
HEALTH_PROFILE_FACT_KINDS,
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
/**
@@ -435,7 +437,7 @@ export async function loadEmergency(
export async function loadAnamnesis(
userId: string,
): Promise<NonNullable<DoctorReportData["anamnesis"]>> {
const [profile, factRows] = await Promise.all([
const [profile, factRows, life] = await Promise.all([
prisma.userHealthProfile.findUnique({
where: { userId },
select: { conditionsEncrypted: true },
@@ -443,11 +445,15 @@ export async function loadAnamnesis(
prisma.healthProfileFactRevision.findMany({
where: {
userId,
// The three closed kinds; the life-circumstance kinds arrive through
// `loadLifeClinicalSummary` with their own codec.
kind: { in: [...HEALTH_PROFILE_FACT_KINDS] },
validUntil: null,
supersededByRevisionId: null,
},
select: { kind: true, valueEncrypted: true },
}),
loadLifeClinicalSummary(userId),
]);
let conditions: string | null = null;
@@ -492,5 +498,6 @@ export async function loadAnamnesis(
DoctorReportData["anamnesis"]
>["shiftSchedule"],
unreadableFacts,
life,
};
}
+14 -15
View File
@@ -29,10 +29,12 @@ import { encryptNote } from "@/lib/crypto/note-cipher";
import { getEvent } from "@/lib/logging/context";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
isHealthProfileFactValue,
type HealthProfileAiSection,
type HealthProfileFactKind,
type StoredHealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
import {
isStoredFactPlaintext,
type StoredFactKind,
} from "@/lib/validations/life-circumstances";
import type {
AdvanceDirectiveStatusValue,
EmergencyBloodTypeValue,
@@ -77,7 +79,7 @@ export interface HealthProfileBackupEntry {
conditionsEncrypted?: string | null;
allergiesEncrypted?: string | null;
coachFocusEncrypted?: string | null;
aiIncludedSections: HealthProfileAiSection[];
aiIncludedSections: StoredHealthProfileAiSection[];
/**
* Emergency ("Notfalldaten") profile. The three enums are plaintext closed
* sets, carried by value in both purposes exactly like `aiIncludedSections`.
@@ -116,7 +118,7 @@ export interface HealthProfileBackupEntry {
export interface HealthProfileFactBackupEntry {
id: string;
kind: HealthProfileFactKind;
kind: StoredFactKind;
/** Portable exports carry readable plaintext; DR payloads leave this null. */
value: string | null;
/** Base64 encrypted envelope, present only in disaster-recovery payloads. */
@@ -345,7 +347,7 @@ export async function buildProfileBackupSection(
allergies: null,
coachFocus: null,
aiIncludedSections: (profileRow.aiIncludedSections as
HealthProfileAiSection[] | undefined) ?? [
StoredHealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson),
@@ -394,7 +396,7 @@ export async function buildProfileBackupSection(
decryptFailures,
),
aiIncludedSections: (profileRow.aiIncludedSections as
HealthProfileAiSection[] | undefined) ?? [
StoredHealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson),
@@ -424,7 +426,7 @@ export async function buildProfileBackupSection(
for (const fact of factRows) {
healthProfileFacts.push({
id: fact.id,
kind: fact.kind as HealthProfileFactKind,
kind: fact.kind as StoredFactKind,
value: null,
valueEncrypted: toBase64(fact.valueEncrypted)!,
validFrom: fact.validFrom.toISOString(),
@@ -443,10 +445,10 @@ export async function buildProfileBackupSection(
`fact.${fact.kind}`,
decryptFailures,
);
const kind = fact.kind as HealthProfileFactKind;
const kind = fact.kind as StoredFactKind;
if (
value === null ||
!isHealthProfileFactValue(kind, value) ||
!isStoredFactPlaintext(kind, value) ||
(fact.validUntil !== null && fact.validUntil <= fact.validFrom)
) {
continue;
@@ -658,10 +660,7 @@ function resolveFactValue(
if (entry.valueEncrypted !== undefined) {
return decodeEncryptedField(entry.valueEncrypted, `fact.${entry.kind}`);
}
if (
entry.value === null ||
!isHealthProfileFactValue(entry.kind, entry.value)
) {
if (entry.value === null || !isStoredFactPlaintext(entry.kind, entry.value)) {
throw new Error(
`Health profile fact '${entry.kind}' has no valid readable value`,
);
@@ -755,7 +754,7 @@ export async function restoreProfileData(
});
const liveSections = (
(liveProfileScope?.aiIncludedSections as
HealthProfileAiSection[] | undefined) ??
StoredHealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS
)
.slice()
+134 -1
View File
@@ -121,7 +121,7 @@ export function anamnesisObservationsFromReportData(
"SHIFT_SCHEDULE",
];
return kinds.map((kind, index) => {
const facts = kinds.map((kind, index): FhirObservation => {
const definition = FACT_DEFINITIONS[kind];
const value = values[kind];
const unreadable = anamnesis.unreadableFacts.includes(kind);
@@ -157,6 +157,7 @@ export function anamnesisObservationsFromReportData(
: {}),
};
});
return [...facts, ...lifeObservationsFromReportData(data)];
}
/** Raw narrative text; the document builder performs XHTML escaping once. */
@@ -182,5 +183,137 @@ export function anamnesisNarrativeFromReportData(
`Smoking status: ${factText("SMOKING_STATUS", anamnesis.smokingStatus)}.`,
`Alcohol pattern: ${factText("ALCOHOL_PATTERN", anamnesis.alcoholPattern)}.`,
`Shift schedule: ${factText("SHIFT_SCHEDULE", anamnesis.shiftSchedule)}.`,
...(anamnesis.life?.packYears != null
? [`Pack-years: ${anamnesis.life.packYears}.`]
: []),
...(anamnesis.life?.auditC
? [
`AUDIT-C (screening): ${anamnesis.life.auditC.totalScore} of 12 on ${anamnesis.life.auditC.takenAt.slice(0, 10)}.`,
]
: []),
].join(" ");
}
/**
* v1.43 (#614) — the clinical part of the life circumstances: AUDIT-C as a
* screening score (LOINC 75626-2), pack-years (SNOMED CT 401201003), and the
* self-reported activity and work context as text observations. Only present
* values emit; diet and goals never do (coach-only by design).
*/
function lifeObservationsFromReportData(
data: DoctorReportData,
): FhirObservation[] {
const life = data.anamnesis?.life;
if (!life) return [];
const out: FhirObservation[] = [];
const base = (id: string, category: string) => ({
resourceType: "Observation" as const,
id,
status: "final" as const,
category: [categoryConcept(category)],
subject: patientRef,
});
if (life.auditC) {
const a = life.auditC;
const verdict =
a.aboveCutoff === null || a.cutoffValue === null
? "No sex-specific cut-off applied (no binary sex in the profile)."
: `${a.aboveCutoff ? "At or above" : "Below"} the screening cut-off of ${a.cutoffValue} (${a.cutoffSourceLabel ?? "source not named"}).`;
out.push({
...base("obs-life-audit-c", "survey"),
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "75626-2",
display: "Total score [AUDIT-C]",
},
],
text: "AUDIT-C total score",
},
effectiveDateTime: a.takenAt,
valueInteger: a.totalScore,
note: [
{
text: `Screening result, not a diagnosis. ${verdict}`,
},
],
});
}
if (life.packYears !== null) {
out.push({
...base("obs-life-pack-years", "social-history"),
code: {
coding: [
{
system: SNOMED_SYSTEM,
code: "401201003",
display: "Cigarette pack-years",
},
],
text: "Cigarette pack-years",
},
valueQuantity: { value: life.packYears, unit: "pack-years" },
note: [
{
text: "Computed from self-reported cigarettes per day and years smoked.",
},
],
});
}
if (
life.activityMinutesPerWeek !== null ||
life.sittingHoursWorkday !== null
) {
const parts: string[] = [];
if (life.activityMinutesPerWeek !== null) {
parts.push(
`${life.activityMinutesPerWeek} minutes of moderate to strenuous activity per week (Exercise Vital Sign)`,
);
}
if (life.sittingHoursWorkday !== null) {
parts.push(`${life.sittingHoursWorkday} hours sitting on a workday`);
}
out.push({
...base("obs-life-activity", "social-history"),
code: { text: "Self-reported physical activity" },
valueString: `${parts.join("; ")}.`,
});
}
const work = life.work;
if (work) {
const parts = [
work.situation ? `situation: ${work.situation.replace(/_/g, " ")}` : null,
work.physicalNature
? `physical nature: ${work.physicalNature.replace(/_/g, " ")}`
: null,
work.nightWork === null
? null
: `night work: ${work.nightWork ? "yes" : "no"}`,
work.exposures.length > 0
? `exposures: ${work.exposures.map((e) => e.replace(/_/g, " ")).join(", ")}`
: null,
].filter((part): part is string => part !== null);
if (parts.length > 0) {
out.push({
...base("obs-life-work", "social-history"),
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "11341-5",
display: "History of Occupation",
},
],
text: "Self-reported work context",
},
valueString: `${parts.join("; ")}.`,
});
}
}
return out;
}
-34
View File
@@ -1,34 +0,0 @@
/**
* The answer of a route whose contract is published but whose behaviour has
* not landed on this server yet.
*
* A route answers this only after it has authenticated the caller, resolved
* the record, applied its module gate and validated the request exactly as
* the published contract says, so a client already sees the real refusals
* (401, 403, 404, 422, 429). What is missing is the effect, and the body says
* so plainly instead of pretending: `501` with `errorCode: "not_implemented"`.
* Nothing calls these routes in normal use: every client path to them sits
* behind a server feature flag that stays `false` until the route is filled.
*
* `contract-stub-inventory.test.ts` freezes the set of route files that still
* answer this; it has to be empty before a release is tagged.
*/
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
/**
* 501 for an operation whose contract exists and whose effect does not yet.
* `operation` is a stable `<surface>.<noun>.<verb>` name for the wide event.
*/
export function notImplemented(operation: string): Response {
annotate({
action: { name: "contract.not_implemented" },
meta: { operation },
});
return apiError("Not implemented on this server yet", 501, {
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
operation,
});
}
@@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the DB so the builder is hermetic — we control the rows.
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } },
prisma: {
measurement: { findMany: (...a: unknown[]) => findMany(...a) },
// v1.43 — the free-weekday answers; none here, so the weekend default.
healthProfileFactRevision: { findMany: vi.fn(async () => []) },
},
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
@@ -146,6 +150,21 @@ describe("computeSleepRhythmFromNights window cap", () => {
// rhythm route (42-day read) both depend on to render identical values.
expect(oneYear.chronotype).toEqual(sixWeeks.chronotype);
});
it("counts the answered free weekdays as free nights instead of the weekend (v1.43)", () => {
const nights = trailingNights(42);
const weekend = computeSleepRhythmFromNights(nights, 420);
const tueWed = computeSleepRhythmFromNights(nights, 420, "COMPUTED", [
{
validFrom: new Date("2026-01-01T00:00:00.000Z"),
validUntil: null,
days: [2, 3, 4],
},
]);
expect(weekend.chronotype.freeNightsCounted).toBe(12);
// Three free weekdays over six weeks: 18 free nights, not the weekend 12.
expect(tueWed.chronotype.freeNightsCounted).toBe(18);
});
});
describe("computeAverageSleep", () => {
+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,
);
}
@@ -0,0 +1,113 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
auditCCutoff,
auditCInstrument,
auditCTotal,
isValidatedAuditCLocale,
normaliseAuditCLocale,
} from "../audit-c";
import { AUDIT_C_ITEMS, AUDIT_C_STANDARD_DRINK } from "../instruments";
describe("AUDIT-C cut-offs", () => {
it("German entry, woman, sum 4: at the S3 cut-off of 4", () => {
const cutoff = auditCCutoff("de", "FEMALE", "de");
expect(cutoff).toMatchObject({
value: 4,
sex: "female",
source: "S3_ALCOHOL_DE",
});
expect(cutoff?.sourceLabel).toContain("Deutschland");
expect(auditCTotal([2, 1, 1]) >= cutoff!.value).toBe(true);
});
it("German men 5, Korean 5 and 4, everyone else Bradley 4 and 3", () => {
expect(auditCCutoff("de", "MALE", "de")?.value).toBe(5);
expect(auditCCutoff("ko", "MALE", "ko")).toMatchObject({
value: 5,
source: "KNHANES_KR",
});
expect(auditCCutoff("ko", "FEMALE", "ko")?.value).toBe(4);
expect(auditCCutoff("en", "MALE", "en")).toMatchObject({
value: 4,
source: "BRADLEY_2007",
});
expect(auditCCutoff("fr", "FEMALE", "fr")?.value).toBe(3);
});
it("has no cut-off without a binary sex in the profile", () => {
expect(auditCCutoff("de", null, "de")).toBeNull();
expect(auditCCutoff("de", "OTHER", "de")).toBeNull();
});
it("names the country in the reader's language", () => {
expect(auditCCutoff("en", "MALE", "es")?.sourceLabel).toContain(
"Estados Unidos",
);
});
});
describe("AUDIT-C instrument", () => {
it("serves the validated wording in the account language", () => {
const de = auditCInstrument("de");
expect(de.locale).toBe("de");
expect(de.validatedLocale).toBe(true);
expect(de.items).toHaveLength(3);
expect(de.items[0].options.map((o) => o.value)).toEqual([0, 1, 2, 3, 4]);
expect(de.attribution).toBe(
"As a WHO-approved instrument, the AUDIT is in the public domain.",
);
});
it("falls back to the WHO English items, never a translation of its own", () => {
for (const locale of ["fr", "es", "it"]) {
const instrument = auditCInstrument(locale);
expect(instrument.locale).toBe("en");
expect(instrument.validatedLocale).toBe(false);
expect(instrument.items[0].stem).toBe(AUDIT_C_ITEMS.en.items[0].stem);
// The help text is still in the account language.
expect(instrument.standardDrink).toBe(AUDIT_C_STANDARD_DRINK[locale]);
}
});
it("knows which languages are validated", () => {
expect(["de", "en", "ko", "pl"].every(isValidatedAuditCLocale)).toBe(true);
expect(["es", "fr", "it"].some(isValidatedAuditCLocale)).toBe(false);
expect(normaliseAuditCLocale("pt-BR")).toBe("en");
expect(normaliseAuditCLocale("de-AT")).toBe("de");
});
it("carries a standard-drink sentence for every app language", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
expect(AUDIT_C_STANDARD_DRINK[locale]?.length).toBeGreaterThan(20);
}
});
/**
* The validated wording must not change: re-wording invalidates the score,
* and the WHO licence forbids material changes. The hashes pin the exact
* characters per language; a change here needs a new primary source, and
* the source note in `instruments.ts` changes with it.
*/
it("keeps the validated wording byte for byte", () => {
const hashes = Object.fromEntries(
Object.entries(AUDIT_C_ITEMS).map(([locale, wording]) => [
locale,
createHash("sha256")
.update(JSON.stringify(wording.items))
.digest("hex")
.slice(0, 16),
]),
);
expect(hashes).toEqual(PINNED);
});
});
const PINNED: Record<string, string> = {
de: "4c072c57581dfa60",
en: "7d91058682d56051",
ko: "5617030281f6f963",
pl: "1a06ca242af64e54",
};
+161
View File
@@ -0,0 +1,161 @@
/**
* v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances.
*
* Three items of the WHO AUDIT, 0 to 4 points each, total 0 to 12. A
* screening result, never a diagnosis: the read route shows the sum and, when
* the profile states a sex, whether it reaches the screening cut-off of the
* entry's country, with that source named.
*
* Cut-offs follow the language of the entry, because the validated wording a
* person answered is the one the national cut-off was set against:
* - `de`: German S3 guideline on alcohol-related disorders (AWMF 076-001):
* men 5, women 4.
* - `ko`: Korean AUDIT-C cut-offs from KNHANES data: men 5, women 4.
* - every other language: Bradley et al. 2007 (US primary care): men 4,
* women 3.
* A profile without a sex, or with `OTHER`, gets no cut-off at all: the
* tables have no third row, and picking one of the two would be a guess.
*
* The item wording lives in `instruments.ts` (`AUDIT_C_ITEMS`), with the
* source of every translation beside it.
*/
import { binaryReferenceSex } from "@/lib/profile/sex";
import { countryLabel } from "@/lib/profile/country-label";
import {
AUDIT_C_ATTRIBUTION,
AUDIT_C_ITEMS,
AUDIT_C_STANDARD_DRINK,
} from "@/lib/mental-health/instruments";
export type AuditCCutoffSource =
"S3_ALCOHOL_DE" | "KNHANES_KR" | "BRADLEY_2007";
export interface AuditCCutoff {
value: number;
sex: "male" | "female";
source: AuditCCutoffSource;
sourceLabel: string;
sourceUrl: string;
}
const CUTOFFS: Readonly<
Record<
AuditCCutoffSource,
{
male: number;
female: number;
citation: string;
country: "DE" | "KR" | "US";
url: string;
}
>
> = {
S3_ALCOHOL_DE: {
male: 5,
female: 4,
citation:
"S3-Leitlinie Screening, Diagnose und Behandlung alkoholbezogener Störungen (AWMF 076-001)",
country: "DE",
url: "https://alkoholleitlinie.de/diagnostik/test-audit-c/",
},
KNHANES_KR: {
male: 5,
female: 4,
citation:
"AUDIT-C cut-offs from the Korea National Health and Nutrition Examination Survey (KNHANES)",
country: "KR",
url: "https://pmc.ncbi.nlm.nih.gov/articles/PMC6039362",
},
BRADLEY_2007: {
male: 4,
female: 3,
citation: "Bradley KA et al., Alcohol Clin Exp Res 2007;31(7):1208-1217",
country: "US",
url: "https://onlinelibrary.wiley.com/doi/10.1111/j.1530-0277.2007.00403.x",
},
};
/** The cut-off table an entry in this language is read against. */
export function auditCCutoffSource(locale: string): AuditCCutoffSource {
if (locale === "de") return "S3_ALCOHOL_DE";
if (locale === "ko") return "KNHANES_KR";
return "BRADLEY_2007";
}
/**
* The cut-off for an entry's language and the profile's sex; null when the
* profile states no binary sex. `labelLocale` is the language the source line
* is written for (the account's), so the country reads in that language.
*/
export function auditCCutoff(
entryLocale: string,
sex: string | null,
labelLocale: string,
): AuditCCutoff | null {
const binary = binaryReferenceSex(sex);
if (!binary) return null;
const source = auditCCutoffSource(entryLocale);
const row = CUTOFFS[source];
const key = binary === "MALE" ? "male" : "female";
return {
value: row[key],
sex: key,
source,
sourceLabel: `${row.citation} (${countryLabel(row.country, labelLocale)})`,
sourceUrl: row.url,
};
}
export function auditCTotal(items: readonly number[]): number {
return items.reduce((sum, item) => sum + item, 0);
}
/** The app languages; anything else is read as English. */
const APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Normalise a requested or stored locale onto the app languages. */
export function normaliseAuditCLocale(
locale: string | null | undefined,
): string {
const base = (locale ?? "en").slice(0, 2).toLowerCase();
return (APP_LOCALES as readonly string[]).includes(base) ? base : "en";
}
/** Whether `locale` has a validated translation of the three items. */
export function isValidatedAuditCLocale(locale: string): boolean {
return locale in AUDIT_C_ITEMS;
}
export interface AuditCInstrument {
items: Array<{
stem: string;
options: Array<{ value: number; label: string }>;
}>;
locale: string;
validatedLocale: boolean;
standardDrink: string;
attribution: string;
}
/**
* The instrument as a client renders it: the three items verbatim in the
* account language when a validated translation exists, the WHO English
* original otherwise (`validatedLocale: false`). Never a translation of our
* own. The standard-drink sentence is help text in the account language.
*/
export function auditCInstrument(
accountLocale: string | null | undefined,
): AuditCInstrument {
const locale = normaliseAuditCLocale(accountLocale);
const wording = AUDIT_C_ITEMS[locale] ?? AUDIT_C_ITEMS.en;
return {
items: wording.items.map((item) => ({
stem: item.stem,
options: item.options.map((label, value) => ({ value, label })),
})),
locale: wording.locale,
validatedLocale: wording.locale === locale,
standardDrink: AUDIT_C_STANDARD_DRINK[locale] ?? AUDIT_C_STANDARD_DRINK.en,
attribution: AUDIT_C_ATTRIBUTION,
};
}
+204
View File
@@ -357,3 +357,207 @@ export function isSafetyFlagged(
if (idx === null) return false;
return (items[idx] ?? 0) > 0;
}
// ── AUDIT-C (v1.43, #614) ────────────────────────────────────────────────────
//
// The first three questions of the WHO AUDIT, served verbatim by
// `GET /api/anamnesis/life-circumstances` (`auditC.instrument`) so the web and
// the app render one wording. Not part of `INSTRUMENTS`: AUDIT-C is stored
// beside the screeners but never listed or scored by a mental-health surface
// (older app builds decode that instrument list closed). Scoring and the
// national cut-offs live in `audit-c.ts`.
//
// Licence: "As a WHO-approved instrument, the AUDIT is in the public domain"
// (auditscreen.org FAQ); reproduction is free as long as the wording is not
// materially changed and the WHO note is shown. Adapting the standard drink to
// the country is foreseen by the WHO manual, which is why that sentence is
// help text per language and not part of the items.
//
// Wording is taken from primary sources only. A language without a verified,
// published validated translation ships the WHO English original with
// `validatedLocale: false`, never a translation of our own:
// - en: Babor et al., AUDIT manual, WHO/MSD/MSB/01.6a (2001), Box 4.
// https://www.paho.org/sites/default/files/Auditmanual_ENG.pdf
// - de: AUDIT Lübeck (Rumpf et al.), the version attached to the German S3
// guideline (AWMF 076-001), as distributed on auditscreen.org:
// https://auditscreen.org/cmsb/uploads/audit-german-l-beck.pdf
// (the guideline PDF's own annex misprints item 2 as "7 oder 8", so
// the Lübeck sheet is the one reproduced).
// - ko: AUDIT-KR, Kim CG, Kim JS et al., Korean J Fam Med 2014;35:2-10,
// Appendix 1. https://www.kjfm.or.kr/upload/pdf/kjfm-35-2.pdf
// Item 2 prints two answer rows (soju bottles and standard glasses of
// other drinks); each option carries both, in the source's order. Item 3
// uses the Korean threshold (more than one bottle of soju, or five
// standard glasses), which the KNHANES cut-offs were set against.
// - pl: Klimkiewicz A et al., "Psychometric properties of the Polish version
// of the AUDIT", Drug Alcohol Depend 2021; interview form as published
// on auditscreen.org:
// https://auditscreen.org/cmsb/uploads/audit_-polish_-interview_new.pdf
// - es, fr, it: English with `validatedLocale: false`. Published official
// wordings exist (WHO manual in Spanish; HAS 2023 guide in French; an
// ISS leaflet in Italian), but no validation paper for them was read
// and the Spanish and Italian sources carry misprints in the options.
// Enabling one needs the wording read from its validation source.
export const AUDIT_C_ATTRIBUTION =
"As a WHO-approved instrument, the AUDIT is in the public domain.";
interface AuditCItemWording {
stem: string;
/** Answer labels in score order, 0 to 4. */
options: readonly [string, string, string, string, string];
}
interface AuditCWording {
locale: string;
items: readonly [AuditCItemWording, AuditCItemWording, AuditCItemWording];
}
/** Validated item wording by language. A language missing here renders `en`. */
export const AUDIT_C_ITEMS: Readonly<Record<string, AuditCWording>> = {
en: {
locale: "en",
items: [
{
stem: "How often do you have a drink containing alcohol?",
options: [
"Never",
"Monthly or less",
"2 to 4 times a month",
"2 to 3 times a week",
"4 or more times a week",
],
},
{
stem: "How many drinks containing alcohol do you have on a typical day when you are drinking?",
options: ["1 or 2", "3 or 4", "5 or 6", "7, 8, or 9", "10 or more"],
},
{
stem: "How often do you have six or more drinks on one occasion?",
options: [
"Never",
"Less than monthly",
"Monthly",
"Weekly",
"Daily or almost daily",
],
},
],
},
de: {
locale: "de",
items: [
{
stem: "Wie oft nehmen Sie ein alkoholisches Getränk zu sich?",
options: [
"Niemals",
"1 mal im Monat oder seltener",
"2 bis 4 mal im Monat",
"2 bis 3 mal pro Woche",
"4 mal oder mehrmals in der Woche",
],
},
{
stem: "Wenn Sie alkoholische Getränke zu sich nehmen, wieviel trinken Sie dann typischerweise an einem Tag?",
options: ["1 - 2", "3 - 4", "5 - 6", "7 - 9", "10 oder mehr"],
},
{
stem: "Wie oft trinken Sie 6 oder mehr Gläser Alkohol bei einer Gelegenheit?",
options: [
"Niemals",
"Seltener als 1 mal im Monat",
"1 mal im Monat",
"1 mal pro Woche",
"Täglich oder fast täglich",
],
},
],
},
ko: {
locale: "ko",
items: [
{
stem: "술을 마시는 횟수는 어느 정도입니까?",
options: [
"전혀 안 마신다",
"한 달에 1회 이하",
"한 달에 2–4회",
"일주일에 2–3회",
"일주일에 4회 이상",
],
},
{
stem: "술을 마시는 날은 보통 어느 정도를 마십니까?",
options: [
"소주: 반 병 이하 / 기타 술: 1–2잔",
"소주: 1병 이하 / 기타 술: 3–4잔",
"소주: 1.5병 정도 / 기타 술: 5–6잔",
"소주: 2병 정도 / 기타 술: 7–9잔",
"소주: 2.5병 이상 / 기타 술: 10잔 이상",
],
},
{
stem: "한 번의 술좌석에서 소주 1병을 초과해서 마시는 횟수는 어느 정도입니까?(기타 술은 표준 5잔 이상)",
options: [
"전혀 없다",
"한 달에 1회 미만",
"한 달에 1회정도",
"일주일에 1회 정도",
"거의 매일",
],
},
],
},
pl: {
locale: "pl",
items: [
{
stem: "Jak często pije Pani/Pan napoje zawierające alkohol?",
options: [
"nigdy",
"raz w miesiącu lub rzadziej",
"2 do 4 razy w miesiącu",
"2 do 3 razy w tygodniu",
"4 razy w tygodniu lub częściej",
],
},
{
stem: "Ile standardowych porcji alkoholu wypija Pani/Pan podczas typowego dnia, w którym spożywa Pani/Pan alkohol?",
options: [
"1 lub 2",
"3 lub 4",
"5 lub 6",
"7, 8 lub 9",
"10 lub więcej",
],
},
{
stem: "Jak często wypija Pani/Pan sześć lub więcej porcji alkoholu przy jednej okazji?",
options: [
"nigdy",
"rzadziej niż raz w miesiącu",
"raz w miesiącu",
"raz w tygodniu",
"codziennie lub prawie codziennie",
],
},
],
},
};
/**
* One sentence on what counts as one drink, in each app language. Help text,
* not part of the validated items. German and French follow their national
* sources (alkoholleitlinie.de: about 10 to 12 g; HAS: 10 g), Korean quotes
* the AUDIT-KR footnote, and the languages that show the English items use
* the WHO manual's 10 g so the note matches the wording beside it.
*/
export const AUDIT_C_STANDARD_DRINK: Readonly<Record<string, string>> = {
en: "One drink means a standard drink of about 10 g of pure alcohol, for example 330 ml of beer at 5%, 140 ml of wine at 12% or 40 ml of spirits at 40%.",
de: "Ein Glas Alkohol entspricht etwa 10 bis 12 g reinem Alkohol, zum Beispiel 0,25 l Bier, 0,125 l Wein oder Sekt oder 4 cl Spirituosen.",
es: "Una bebida equivale a una bebida estándar de unos 10 g de alcohol puro, la definición de la OMS que siguen estas preguntas.",
fr: "Un verre standard contient 10 g d'alcool pur, par exemple 25 cl de bière à 5°, 10 cl de vin à 12° ou 3 cl d'alcool à 40°.",
it: "Una bevanda corrisponde a una bevanda standard di circa 10 g di alcol puro, la definizione dell'OMS su cui si basano queste domande.",
pl: "Standardowa porcja alkoholu to około 10 g czystego alkoholu, zgodnie z definicją WHO, na której opierają się te pytania.",
ko: "표준 1잔: 양주잔으로 양주 1잔, 와인잔으로 와인 1잔, 막걸리 1사발이 각각 표준 1잔이다. 맥주는 캔맥주 1캔 또는 작은 병맥주 1병이며 생맥주는 500 mL가 표준 1잔이다. 소주는 1/4병이 표준 1잔에 해당한다.",
};
+7 -1
View File
@@ -25,6 +25,7 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { measurementTypesOwnedBy } from "./measurement-scope";
import type { OwnedModuleKey } from "./registry";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
/** The delegates this file reads. Narrow so a worker's client fits too. */
type DomainDataClient = Pick<
@@ -183,7 +184,12 @@ const MODULE_DOMAIN_PROBES: Record<OwnedModuleKey, DomainProbe> = {
mentalHealth: (client, recordId) =>
anyRow(
client.mentalHealthAssessment.findFirst({
where: { userId: recordId, deletedAt: null },
// AUDIT-C (v1.43) belongs to the life circumstances, not this module.
where: {
userId: recordId,
deletedAt: null,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: { id: true },
}),
),
+1 -1
View File
@@ -60,6 +60,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
analytics: ["analytics.invalid_query"],
anamnesis: [
"anamnesis.auditC.invalid",
"anamnesis.auditC.notFound",
"anamnesis.auditC.rateLimited",
"anamnesis.fact.conflict",
"anamnesis.fact.currentExists",
@@ -471,7 +472,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
"not_implemented",
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
+14 -11
View File
@@ -13,12 +13,15 @@ import {
} from "@/lib/validations/about-me";
import {
healthProfileAiSectionSchema,
healthProfileFactCorrectionSchema,
healthProfileFactDtoSchema,
healthProfileFactsResponseSchema,
healthProfileFactWriteSchema,
removedHealthProfileFactSchema,
} from "@/lib/validations/health-profile-facts";
import {
factCorrectionSchema,
factWriteSchema,
lifeFactRevisionSchema,
removedFactSchema,
} from "@/lib/validations/life-circumstances";
import {
emergencyProfileDtoSchema,
emergencyProfileUpdateSchema,
@@ -1526,7 +1529,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Read effective-dated anamnesis facts",
description:
"Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.",
"Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule, and only those three kinds: the life-circumstance kinds are read through `GET /api/anamnesis/life-circumstances`. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.",
responses: {
...recordRefusal(),
"200": {
@@ -1547,12 +1550,12 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Create an anamnesis fact",
description:
"Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.",
"Creates the first current revision for one fact kind. The answer is encrypted before persistence. The three closed kinds take their string value and answer the closed revision shape; the six life-circumstance kinds (v1.43, send only when `features.lifeCircumstances` is true) take an object with `schema: 1` and answer a `LifeFactRevision`. Optional `validFrom` (a calendar day) dates the answer back. A second current value for the same kind returns 409; corrections use the revision endpoint.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
"application/json": { schema: healthProfileFactWriteSchema },
"application/json": { schema: factWriteSchema },
},
},
responses: {
@@ -1562,7 +1565,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]),
"CreateAnamnesisFactResponse",
),
},
@@ -1578,7 +1581,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Correct a current anamnesis fact",
description:
"Closes the current revision at the correction instant and creates a successor. Prior ciphertext and its half-open validity interval remain in history.",
"Closes the current revision at the correction instant (or at the optional `validFrom` day, never earlier than the revision it replaces) and creates a successor. The value takes the stored kind's form: a string for the closed kinds, an object with `schema: 1` for the life-circumstance kinds; any other is 422 `anamnesis.fact.invalidValue`. Prior ciphertext and its half-open validity interval remain in history.",
parameters: [
{
name: "id",
@@ -1590,7 +1593,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: healthProfileFactCorrectionSchema },
"application/json": { schema: factCorrectionSchema },
},
},
responses: {
@@ -1599,7 +1602,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]),
"CorrectAnamnesisFactResponse",
),
},
@@ -1629,7 +1632,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
removedHealthProfileFactSchema,
removedFactSchema,
"RemoveAnamnesisFactResponse",
),
},
+7 -28
View File
@@ -14,8 +14,8 @@ import { z } from "zod/v4";
import {
AUDIT_C_CUTOFF_SOURCES,
LIFE_AI_SECTIONS,
LIFE_FACT_KINDS,
LIFE_SECTION_KEYS,
lifeFactRevisionSchema,
auditCRequestSchema,
lifeAiSectionsPatchSchema,
lifeConfirmSchema,
@@ -26,32 +26,14 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
stdResponses,
} from "./shared";
const lifeFactRevision = z
.object({
id: z.string(),
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
value: z
.unknown()
.describe(
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
),
unreadable: z
.boolean()
.describe("The sealed value could not be opened on this host."),
validFrom: z.iso.datetime({ offset: true }),
validUntil: z.null(),
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
createdAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "LifeFactRevision",
description: "The current revision of one life-circumstance fact.",
});
const lifeFactRevision = lifeFactRevisionSchema.meta({
id: "LifeFactRevision",
description: "The current revision of one life-circumstance fact.",
});
const auditCCutoff = z.object({
value: z.number().int(),
@@ -210,7 +192,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -249,7 +230,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -279,7 +259,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -295,6 +274,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: auditCRequest } },
},
responses: {
...recordRefusal(),
...idempotentWrite(),
"200": {
description: "A replay of an entry already stored.",
@@ -319,7 +299,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -330,6 +309,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Soft delete. 404 `anamnesis.auditC.notFound`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
"200": {
description: "The removed entry.",
content: {
@@ -349,7 +329,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
-13
View File
@@ -557,19 +557,6 @@ export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse {
export const MODULE_DISABLED_DESCRIPTION =
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.';
/**
* The 501 a published operation answers while its behaviour has not landed on
* this server (`src/lib/http/not-implemented.ts`). Spread into an operation's
* responses with the stub, and removed with it.
*/
export const notImplementedResponse = {
"501": {
description:
"Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation already answer as documented. A client reaches this only by ignoring the server feature flag that guards the operation.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const moduleDisabledResponse = {
"403": {
description: MODULE_DISABLED_DESCRIPTION,
@@ -28,6 +28,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: mocks.invalidateInsights,
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ getEvent: vi.fn(() => null) }));
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import {
activityMinutesPerWeek,
computePackYears,
dayTypeForNight,
isReviewDue,
screeningHints,
type ScreeningInputs,
} from "../life-derived";
import type { TobaccoDetailValue } from "@/lib/validations/life-circumstances";
const NOW = new Date("2026-10-16T12:00:00.000Z");
function tobacco(over: Partial<TobaccoDetailValue> = {}): TobaccoDetailValue {
return {
schema: 1,
products: ["cigarettes"],
cigarettesPerDay: 20,
startYear: 1990,
quitMonth: null,
secondhandSmoke: null,
...over,
};
}
describe("pack-years", () => {
it("20 a day from 1990 to 2020 is 30.0", () => {
expect(
computePackYears("FORMER", tobacco({ quitMonth: "2020-06" }), NOW),
).toEqual({ value: 30, status: "computed" });
});
it("runs to the current year for a current smoker, to one decimal", () => {
expect(
computePackYears("CURRENT", tobacco({ cigarettesPerDay: 15 }), NOW),
).toEqual({ value: 27, status: "computed" });
expect(
computePackYears(
"CURRENT",
tobacco({ cigarettesPerDay: 7, startYear: 2010 }),
NOW,
),
).toEqual({ value: 5.6, status: "computed" });
});
it("is missing without a start year, never estimated", () => {
expect(
computePackYears("CURRENT", tobacco({ startYear: null }), NOW),
).toEqual({ value: null, status: "missing_inputs" });
expect(computePackYears("CURRENT", null, NOW).status).toBe(
"missing_inputs",
);
});
it("needs the quit date of a former smoker", () => {
expect(computePackYears("FORMER", tobacco(), NOW).status).toBe(
"missing_inputs",
);
});
it("does not apply to someone who never smoked or smokes no cigarettes", () => {
expect(computePackYears("NEVER", tobacco(), NOW).status).toBe(
"not_applicable",
);
expect(
computePackYears(
"CURRENT",
tobacco({ products: ["e_cigarette"], cigarettesPerDay: null }),
NOW,
).status,
).toBe("not_applicable");
});
});
describe("activity minutes", () => {
it("multiplies the two Exercise Vital Sign answers", () => {
expect(activityMinutesPerWeek(5, 30)).toEqual({
value: 150,
meetsWho150: true,
});
expect(activityMinutesPerWeek(2, 30)).toEqual({
value: 60,
meetsWho150: false,
});
});
it("stays null with either answer missing", () => {
expect(activityMinutesPerWeek(null, 30)).toEqual({
value: null,
meetsWho150: null,
});
});
});
describe("free weekdays per night", () => {
// 2026-10-13 is a Tuesday, 2026-10-17 a Saturday.
const spans = [
{
validFrom: new Date("2026-10-01T00:00:00.000Z"),
validUntil: null,
days: [2, 3],
},
];
it("reads a night against the answer in force: Tuesday and Wednesday free", () => {
expect(dayTypeForNight("2026-10-13", spans)).toBe("free");
expect(dayTypeForNight("2026-10-14", spans)).toBe("free");
expect(dayTypeForNight("2026-10-17", spans)).toBe("work");
});
it("keeps the weekend default for nights before the answer", () => {
expect(dayTypeForNight("2026-09-26", spans)).toBe("free");
expect(dayTypeForNight("2026-09-29", spans)).toBe("work");
});
it("is the weekend default with no answer at all", () => {
expect(dayTypeForNight("2026-10-17", [])).toBe("free");
expect(dayTypeForNight("2026-10-13", [])).toBe("work");
});
});
describe("review due", () => {
it("after a year, and never without an answer", () => {
expect(isReviewDue(new Date("2025-10-15T00:00:00.000Z"), NOW)).toBe(true);
expect(isReviewDue(new Date("2026-01-01T00:00:00.000Z"), NOW)).toBe(false);
expect(isReviewDue(null, NOW)).toBe(false);
});
});
describe("screening hints", () => {
function input(over: Partial<ScreeningInputs> = {}): ScreeningInputs {
const detail = over.detail ?? tobacco({ startYear: 1985 });
return {
ageYears: 60,
sex: "MALE",
smokingStatus: "CURRENT",
detail,
packYears: computePackYears(over.smokingStatus ?? "CURRENT", detail, NOW),
locale: "de",
now: NOW,
...over,
};
}
it("suggests the German low-dose CT with the G-BA source and country", () => {
const [lung] = screeningHints(input());
expect(lung.id).toBe("lung_ldct");
expect(lung.status).toBe("suggested");
expect(lung.region).toBe("DE");
expect(lung.sourceLabel).toContain("G-BA");
expect(lung.sourceLabel).toContain("Deutschland");
expect(lung.sourceUrl).toMatch(/^https:\/\/www\.g-ba\.de\//);
});
it("names the failing criteria", () => {
const [lung] = screeningHints(
input({
ageYears: 45,
detail: tobacco({ startYear: 2015, cigarettesPerDay: 10 }),
}),
);
expect(lung.status).toBe("not_eligible");
expect(lung.reasonKeys).toEqual(
expect.arrayContaining([
"age_out_of_range",
"pack_years_below_15",
"smoked_years_below_25",
]),
);
});
it("says unknown, not no, when the birth date is missing", () => {
const [lung] = screeningHints(input({ ageYears: null }));
expect(lung.status).toBe("unknown");
expect(lung.reasonKeys).toContain("missing_birth_date");
});
it("uses the US rules outside German, with the country in the account language", () => {
const [lung, aaa] = screeningHints(input({ locale: "fr" }));
expect(lung.region).toBe("US");
expect(lung.sourceLabel).toContain("USPSTF");
expect(lung.sourceLabel).toContain("États-Unis");
expect(aaa.status).toBe("not_eligible");
expect(aaa.reasonKeys).toContain("age_out_of_range");
});
it("offers the aorta ultrasound to every man from 65 in Germany, not to women", () => {
expect(screeningHints(input({ ageYears: 66 }))[1].status).toBe("suggested");
const women = screeningHints(input({ ageYears: 66, sex: "FEMALE" }))[1];
expect(women.status).toBe("not_eligible");
expect(women.reasonKeys).toContain("sex_not_male");
expect(screeningHints(input({ ageYears: 66, sex: null }))[1].status).toBe(
"unknown",
);
});
it("asks the US rule for a smoking history", () => {
const never = screeningHints(
input({ ageYears: 70, locale: "en", smokingStatus: "NEVER" }),
);
expect(never[0].reasonKeys).toEqual(["never_smoked"]);
expect(never[1].reasonKeys).toContain("never_smoked");
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Country names in the app languages, for the source lines of the life
* circumstances (screening hints, AUDIT-C cut-offs): a rule always says whose
* rule it is, in the language of the account reading it.
*
* Client-safe, no imports.
*/
export type SourceCountry = "DE" | "KR" | "US";
const NAMES: Readonly<Record<string, Readonly<Record<SourceCountry, string>>>> =
{
de: { DE: "Deutschland", KR: "Südkorea", US: "USA" },
en: { DE: "Germany", KR: "South Korea", US: "United States" },
es: { DE: "Alemania", KR: "Corea del Sur", US: "Estados Unidos" },
fr: { DE: "Allemagne", KR: "Corée du Sud", US: "États-Unis" },
it: { DE: "Germania", KR: "Corea del Sud", US: "Stati Uniti" },
pl: { DE: "Niemcy", KR: "Korea Południowa", US: "Stany Zjednoczone" },
ko: { DE: "독일", KR: "대한민국", US: "미국" },
};
export function countryLabel(
country: SourceCountry,
locale: string | null | undefined,
): string {
return (NAMES[locale ?? "en"] ?? NAMES.en)[country];
}
+216 -57
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,
@@ -11,6 +14,12 @@ import {
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
import {
parseLifeFactValue,
type LifeFactKind,
type LifeFactValue,
type StoredFactKind,
} from "@/lib/validations/life-circumstances";
export interface HealthProfileFactRevisionDto {
id: string;
@@ -26,7 +35,7 @@ export interface HealthProfileFactRevisionDto {
interface FactRevisionRow {
id: string;
kind: HealthProfileFactKind;
kind: StoredFactKind;
valueEncrypted: Uint8Array;
validFrom: Date;
validUntil: Date | null;
@@ -42,7 +51,7 @@ export interface HealthProfileFactsPayload {
export interface RemovedHealthProfileFactDto {
id: string;
kind: HealthProfileFactKind;
kind: StoredFactKind;
removedAt: string;
}
@@ -64,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(
@@ -89,10 +100,11 @@ export function decryptHealthProfileFactValue(
export function toHealthProfileFactDto(
row: FactRevisionRow,
): HealthProfileFactRevisionDto {
const decrypted = decryptHealthProfileFactValue(row.kind, row.valueEncrypted);
const kind = row.kind as HealthProfileFactKind;
const decrypted = decryptHealthProfileFactValue(kind, row.valueEncrypted);
return {
id: row.id,
kind: row.kind,
kind,
value: decrypted.value,
unreadable: decrypted.unreadable,
validFrom: row.validFrom.toISOString(),
@@ -106,8 +118,11 @@ export function toHealthProfileFactDto(
export async function readHealthProfileFacts(
userId: string,
): Promise<HealthProfileFactsPayload> {
// The three closed kinds only. The table also holds the life-circumstance
// kinds since v1.43, and older app builds decode this list against exactly
// three values; those kinds are read through the life-circumstances route.
const rows = (await prisma.healthProfileFactRevision.findMany({
where: { userId },
where: { userId, kind: { in: [...HEALTH_PROFILE_FACT_KINDS] } },
orderBy: [{ kind: "asc" }, { validFrom: "desc" }],
select: {
id: true,
@@ -137,43 +152,165 @@ export async function readHealthProfileFacts(
return { current, history };
}
const FACT_ROW_SELECT = {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
validUntil: true,
provenance: true,
supersededByRevisionId: true,
createdAt: true,
} as const;
/** The plaintext a fact's ciphertext seals: the closed value, or the life value as JSON. */
function sealFactValue(value: string | LifeFactValue): Uint8Array<ArrayBuffer> {
return encryptToBytes(
typeof value === "string" ? value : JSON.stringify(value),
);
}
/**
* Open a life-circumstance value and check it against its kind's schema.
* Fail-closed like the closed kinds: ciphertext this host cannot open, or a
* value that no longer fits, reads as `unreadable` and never as an answer.
*/
export function decryptLifeFactValue(
kind: LifeFactKind,
payload: Uint8Array,
): { value: LifeFactValue | null; unreadable: boolean } {
try {
const value = parseLifeFactValue(
kind,
JSON.parse(decryptFromBytes(payload)),
);
if (value) return { value, unreadable: false };
getEvent()?.addWarning(`health profile fact has an invalid ${kind} value`);
} catch (error) {
getEvent()?.addWarning(
`health profile fact decrypt failed (${kind}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return { value: null, unreadable: true };
}
/**
* The instant a new answer starts. A requested day later than now is not an
* answer yet and starts now; one earlier than the end of the kind's previous
* answer would overlap that interval and starts at its end instead, so the
* history stays a sequence of half-open intervals.
*/
async function resolveStart(
tx: Pick<Prisma.TransactionClient, "healthProfileFactRevision">,
userId: string,
kind: StoredFactKind,
requested: Date | undefined,
now: Date,
): Promise<Date> {
if (!requested || requested.getTime() >= now.getTime()) return now;
const previous = await tx.healthProfileFactRevision.findFirst({
where: { userId, kind, validUntil: { not: null } },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
const floor = previous?.validUntil?.getTime() ?? Number.NEGATIVE_INFINITY;
return new Date(Math.max(requested.getTime(), floor));
}
async function createFactRevision(
userId: string,
kind: StoredFactKind,
value: string | LifeFactValue,
now: Date,
validFrom?: Date,
): Promise<FactRevisionRow> {
return prisma.$transaction(async (tx) => {
const start = await resolveStart(tx, userId, kind, validFrom, now);
const row = (await tx.healthProfileFactRevision.create({
data: {
userId,
kind,
valueEncrypted: sealFactValue(value),
validFrom: start,
provenance: "USER_REPORTED",
},
select: FACT_ROW_SELECT,
})) as FactRevisionRow;
await invalidateHealthProfileFactConsumers(userId, tx);
return row;
});
}
export async function createHealthProfileFact(
userId: string,
kind: HealthProfileFactKind,
value: HealthProfileFactValue,
now: Date = new Date(),
validFrom?: Date,
): Promise<HealthProfileFactRevisionDto> {
return prisma.$transaction(async (tx) => {
const row = (await tx.healthProfileFactRevision.create({
data: {
userId,
kind,
valueEncrypted: encryptToBytes(value),
validFrom: now,
provenance: "USER_REPORTED",
},
select: {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
validUntil: true,
provenance: true,
supersededByRevisionId: true,
createdAt: true,
},
})) as FactRevisionRow;
await invalidateHealthProfileFactConsumers(userId, tx);
return toHealthProfileFactDto(row);
});
return toHealthProfileFactDto(
await createFactRevision(userId, kind, value, now, validFrom),
);
}
export async function correctHealthProfileFact(
/** A life-circumstance revision as the life-circumstances read and the fact writes answer it. */
export interface LifeFactRevisionDto {
id: string;
kind: "SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind;
value: string | LifeFactValue | null;
unreadable: boolean;
validFrom: string;
validUntil: null;
provenance: "USER_REPORTED" | "USER_CORRECTION";
createdAt: string;
}
export function toLifeFactRevisionDto(row: {
id: string;
kind: string;
valueEncrypted: Uint8Array;
validFrom: Date;
provenance: "USER_REPORTED" | "USER_CORRECTION";
createdAt: Date;
}): LifeFactRevisionDto {
const kind = row.kind as LifeFactRevisionDto["kind"];
const decrypted =
kind === "SMOKING_STATUS" || kind === "SHIFT_SCHEDULE"
? decryptHealthProfileFactValue(kind, row.valueEncrypted)
: decryptLifeFactValue(kind, row.valueEncrypted);
return {
id: row.id,
kind,
value: decrypted.value,
unreadable: decrypted.unreadable,
validFrom: row.validFrom.toISOString(),
validUntil: null,
provenance: row.provenance,
createdAt: row.createdAt.toISOString(),
};
}
export async function createLifeFact(
userId: string,
kind: LifeFactKind,
value: LifeFactValue,
now: Date = new Date(),
validFrom?: Date,
): Promise<LifeFactRevisionDto> {
return toLifeFactRevisionDto(
await createFactRevision(userId, kind, value, now, validFrom),
);
}
async function correctFactRevision(
userId: string,
revisionId: string,
value: HealthProfileFactValue,
now: Date = new Date(),
): Promise<HealthProfileFactRevisionDto | null> {
value: string | LifeFactValue,
now: Date,
validFrom?: Date,
): Promise<FactRevisionRow | null> {
return prisma.$transaction(async (tx) => {
const current = (await tx.healthProfileFactRevision.findFirst({
where: {
@@ -182,21 +319,18 @@ export async function correctHealthProfileFact(
validUntil: null,
supersededByRevisionId: null,
},
select: {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
validUntil: true,
provenance: true,
supersededByRevisionId: true,
createdAt: true,
},
select: FACT_ROW_SELECT,
})) as FactRevisionRow | null;
if (!current) return null;
// The successor starts at the requested day when there is one, never
// later than now and never at or before the revision it replaces.
const requested = Math.min(
validFrom?.getTime() ?? now.getTime(),
now.getTime(),
);
const effectiveAt = new Date(
Math.max(now.getTime(), current.validFrom.getTime() + 1),
Math.max(requested, current.validFrom.getTime() + 1),
);
const successorId = randomUUID();
const closed = await tx.healthProfileFactRevision.updateMany({
@@ -215,20 +349,11 @@ export async function correctHealthProfileFact(
id: successorId,
userId,
kind: current.kind,
valueEncrypted: encryptToBytes(value),
valueEncrypted: sealFactValue(value),
validFrom: effectiveAt,
provenance: "USER_CORRECTION",
},
select: {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
validUntil: true,
provenance: true,
supersededByRevisionId: true,
createdAt: true,
},
select: FACT_ROW_SELECT,
})) as FactRevisionRow;
await tx.healthProfileFactRevision.update({
@@ -237,10 +362,44 @@ export async function correctHealthProfileFact(
});
await invalidateHealthProfileFactConsumers(userId, tx);
return toHealthProfileFactDto(successor);
return successor;
});
}
export async function correctHealthProfileFact(
userId: string,
revisionId: string,
value: HealthProfileFactValue,
now: Date = new Date(),
validFrom?: Date,
): Promise<HealthProfileFactRevisionDto | null> {
const row = await correctFactRevision(
userId,
revisionId,
value,
now,
validFrom,
);
return row ? toHealthProfileFactDto(row) : null;
}
export async function correctLifeFact(
userId: string,
revisionId: string,
value: LifeFactValue,
now: Date = new Date(),
validFrom?: Date,
): Promise<LifeFactRevisionDto | null> {
const row = await correctFactRevision(
userId,
revisionId,
value,
now,
validFrom,
);
return row ? toLifeFactRevisionDto(row) : null;
}
/**
* Close the current effective interval without deleting or superseding its
* revision. The closed row remains in dated history, while every current-value
@@ -282,7 +441,7 @@ export async function removeHealthProfileFact(
return {
id: current.id,
kind: current.kind as HealthProfileFactKind,
kind: current.kind as StoredFactKind,
removedAt: effectiveAt.toISOString(),
};
});
+700
View File
@@ -0,0 +1,700 @@
/**
* v1.43 (#614) — the life circumstances of a record: the read model behind
* `GET /api/anamnesis/life-circumstances`, the AI switches, the "still true"
* confirmations and the AUDIT-C entries.
*
* The answers are fact revisions (`HealthProfileFactRevision`, sealed values,
* dated, corrected by a successor) and AUDIT-C entries
* (`MentalHealthAssessment` with `instrument = AUDIT_C`, sealed item answers,
* soft delete). Everything a client shows is resolved here: pack-years,
* minutes per week, free weekdays, screening hints, the AUDIT-C cut-off, and
* the instrument wording in the account language.
*
* Server-only.
*/
import { Prisma } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { deriveAgeYears } from "@/lib/ai/coach/about-me";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import {
auditCCutoff,
auditCInstrument,
auditCTotal,
isValidatedAuditCLocale,
normaliseAuditCLocale,
type AuditCCutoff,
type AuditCInstrument,
} from "@/lib/mental-health/audit-c";
import { isModuleEnabled } from "@/lib/modules/gate";
import {
decryptHealthProfileFactValue,
decryptLifeFactValue,
invalidateHealthProfileFactConsumers,
toLifeFactRevisionDto,
type LifeFactRevisionDto,
} from "@/lib/profile/health-facts";
import {
LIFE_SECTIONS,
activityMinutesPerWeek,
computePackYears,
DEFAULT_FREE_WEEKDAYS,
isReviewDue,
screeningHints,
type FreeWeekdaysSpan,
type PackYears,
type LifeClinicalSummary,
type ScreeningHint,
} from "@/lib/profile/life-derived";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
type StoredHealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
import {
LIFE_AI_SECTIONS,
LIFE_SECTION_KEYS,
type ActivityHabitValue,
type FreeWeekdaysValue,
type LifeAiSection,
type LifeFactKind,
type LifeSectionKey,
type TobaccoDetailValue,
type WorkContextValue,
} from "@/lib/validations/life-circumstances";
const MS_PER_DAY = 86_400_000;
/** History entries the read route lists, newest first. */
const AUDIT_C_HISTORY_LIMIT = 20;
/** AUDIT-C is offered again once the newest entry is this old. */
const AUDIT_C_OFFER_AFTER_DAYS = 365;
/** A step count this recent means activity is measured, not only reported. */
const MEASURED_STEPS_DAYS = 30;
/**
* The sealed answers of one AUDIT-C entry. `schema` versions the blob the same
* way the screener responses are versioned.
*/
interface AuditCResponses {
schema: 1;
items: [number, number, number];
}
export interface AuditCResult {
id: string;
takenAt: string;
items: [number, number, number];
totalScore: number;
locale: string;
cutoff: AuditCCutoff | null;
aboveCutoff: boolean | null;
validatedLocale: boolean;
}
export interface LifeCircumstancesDto {
sections: Array<{
key: LifeSectionKey;
aiSection: Exclude<LifeAiSection, "SHIFT_SCHEDULE">;
aiIncluded: boolean;
lastConfirmedAt: string | null;
reviewDue: boolean;
facts: LifeFactRevisionDto[];
}>;
extraAiSections: { SHIFT_SCHEDULE: boolean };
derived: {
packYears: PackYears;
activityMinutesPerWeek: {
value: number | null;
meetsWho150: boolean | null;
measuredStepsAvailable: boolean;
};
freeWeekdays: { days: number[]; source: "fact" | "default_weekend" };
screeningHints: ScreeningHint[];
};
auditC: {
latest: AuditCResult | null;
history: Array<{ id: string; takenAt: string; totalScore: number }>;
offerDue: boolean;
instrument: AuditCInstrument;
};
takeOver: Array<{
field: "conditions" | "allergies";
freeTextPresent: boolean;
structuredCount: number;
available: boolean;
}>;
}
/** The kinds the read route shows: the six new ones and the two closed ones living in a section. */
const SECTION_KINDS = [
...new Set(LIFE_SECTIONS.flatMap((section) => section.kinds)),
];
function storedSections(
stored: readonly string[] | null | undefined,
): Set<StoredHealthProfileAiSection> {
return new Set(
(stored ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as StoredHealthProfileAiSection[],
);
}
/** The stored confirmations, read leniently: a stamp that does not parse counts as none. */
function confirmations(
value: Prisma.JsonValue | null | undefined,
): Map<string, Date> {
const out = new Map<string, Date>();
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
for (const [key, stamp] of Object.entries(value)) {
if (typeof stamp !== "string") continue;
const at = new Date(stamp);
if (!Number.isNaN(at.getTime())) out.set(key, at);
}
return out;
}
function latest(...dates: Array<Date | null | undefined>): Date | null {
let best: Date | null = null;
for (const date of dates) {
if (date && (!best || date > best)) best = date;
}
return best;
}
/** Open the sealed AUDIT-C answers; null when this host cannot read them. */
function openAuditCResponses(payload: Uint8Array): AuditCResponses | null {
try {
const parsed = JSON.parse(
decryptFromBytes(payload),
) as Partial<AuditCResponses>;
const items = parsed.items;
if (
Array.isArray(items) &&
items.length === 3 &&
items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4)
) {
return { schema: 1, items: items as [number, number, number] };
}
getEvent()?.addWarning("audit-c responses shape drifted");
} catch (error) {
getEvent()?.addWarning(
`audit-c responses decrypt failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return null;
}
/** One stored entry scored for a profile sex; the source line follows `labelLocale`. */
export function toAuditCResult(
row: { id: string; takenAt: Date; totalScore: number; locale: string },
items: [number, number, number],
sex: string | null,
labelLocale: string,
): AuditCResult {
const cutoff = auditCCutoff(row.locale, sex, labelLocale);
return {
id: row.id,
takenAt: row.takenAt.toISOString(),
items,
totalScore: row.totalScore,
locale: row.locale,
cutoff,
aboveCutoff: cutoff ? row.totalScore >= cutoff.value : null,
validatedLocale: isValidatedAuditCLocale(row.locale),
};
}
/**
* The dated free-weekday answers of a record, oldest first, for the
* night-by-night day type. Fail-soft: the answers refine a chronotype the
* weekend default already produces, so a failed read falls back to that
* default with a warning instead of taking the sleep surfaces down with it.
*/
export async function loadFreeWeekdaySpans(
userId: string,
): Promise<FreeWeekdaysSpan[]> {
try {
const rows = await prisma.healthProfileFactRevision.findMany({
where: { userId, kind: "FREE_WEEKDAYS" },
orderBy: { validFrom: "asc" },
select: { validFrom: true, validUntil: true, valueEncrypted: true },
});
return rows.flatMap((row) => {
const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted);
const value = opened.value as FreeWeekdaysValue | null;
return value
? [
{
validFrom: row.validFrom,
validUntil: row.validUntil,
days: value.days,
},
]
: [];
});
} catch (error) {
getEvent()?.addWarning(
`free weekdays read failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
return [];
}
}
/** Assemble the whole read model for one record. */
export async function buildLifeCircumstances(
userId: string,
now: Date = new Date(),
): Promise<LifeCircumstancesDto> {
const [
user,
profile,
factRows,
auditRows,
steps,
illnessCount,
allergyCount,
illnessOn,
] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true, locale: true },
}),
prisma.userHealthProfile.findUnique({
where: { userId },
select: {
aiIncludedSections: true,
lifeConfirmedJson: true,
conditionsEncrypted: true,
allergiesEncrypted: true,
},
}),
prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: SECTION_KINDS },
validUntil: null,
supersededByRevisionId: null,
},
select: {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
provenance: true,
createdAt: true,
},
}),
prisma.mentalHealthAssessment.findMany({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: AUDIT_C_HISTORY_LIMIT,
select: {
id: true,
takenAt: true,
totalScore: true,
locale: true,
responsesEncrypted: true,
},
}),
prisma.measurement.findFirst({
where: {
userId,
type: "ACTIVITY_STEPS",
deletedAt: null,
measuredAt: {
gte: new Date(now.getTime() - MEASURED_STEPS_DAYS * MS_PER_DAY),
},
},
select: { id: true },
}),
prisma.illnessEpisode.count({
where: { userId, lifecycle: "CHRONIC_ONGOING", deletedAt: null },
}),
prisma.allergy.count({
where: { userId, status: "ACTIVE", deletedAt: null },
}),
isModuleEnabled(userId, "illness"),
]);
const locale = user?.locale ?? "en";
const sex = user?.gender ?? null;
const included = storedSections(profile?.aiIncludedSections);
const confirmed = confirmations(profile?.lifeConfirmedJson);
const facts = factRows.map(toLifeFactRevisionDto);
const factOf = <T>(kind: string): T | null =>
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
// AUDIT-C: the newest entry whose answers this host can open is the one
// shown in full; the history lists totals, which need no decryption.
let latestAuditC: AuditCResult | null = null;
for (const row of auditRows) {
const opened = openAuditCResponses(row.responsesEncrypted);
if (opened) {
latestAuditC = toAuditCResult(row, opened.items, sex, locale);
break;
}
}
const newestAuditC = auditRows[0]?.takenAt ?? null;
const sections = LIFE_SECTIONS.map((section) => {
const sectionFacts = facts.filter((fact) =>
(section.kinds as readonly string[]).includes(fact.kind),
);
const lastAnswer = latest(
...sectionFacts.map((fact) => new Date(fact.createdAt)),
section.key === "alcohol" ? newestAuditC : null,
);
const lastConfirmed = latest(lastAnswer, confirmed.get(section.key));
return {
key: section.key,
aiSection: section.aiSection,
aiIncluded: included.has(section.aiSection),
lastConfirmedAt: lastConfirmed?.toISOString() ?? null,
reviewDue: isReviewDue(lastConfirmed, now),
facts: sectionFacts,
};
});
const smokingStatus = factOf<string>("SMOKING_STATUS");
const tobacco = factOf<TobaccoDetailValue>("TOBACCO_DETAIL");
const activity = factOf<ActivityHabitValue>("ACTIVITY_HABIT");
const freeDays = factOf<FreeWeekdaysValue>("FREE_WEEKDAYS");
const packYears = computePackYears(smokingStatus, tobacco, now);
const minutes = activityMinutesPerWeek(
activity?.moderateDaysPerWeek ?? null,
activity?.moderateMinutesPerDay ?? null,
);
return {
sections,
extraAiSections: { SHIFT_SCHEDULE: included.has("SHIFT_SCHEDULE") },
derived: {
packYears,
activityMinutesPerWeek: {
...minutes,
measuredStepsAvailable: steps !== null,
},
freeWeekdays: freeDays
? { days: [...freeDays.days].sort((a, b) => a - b), source: "fact" }
: { days: [...DEFAULT_FREE_WEEKDAYS], source: "default_weekend" },
screeningHints: screeningHints({
ageYears: deriveAgeYears(user?.dateOfBirth ?? null, now),
sex,
smokingStatus,
detail: tobacco,
packYears,
locale,
now,
}),
},
auditC: {
latest: latestAuditC,
history: auditRows.map((row) => ({
id: row.id,
takenAt: row.takenAt.toISOString(),
totalScore: row.totalScore,
})),
offerDue:
newestAuditC === null ||
now.getTime() - newestAuditC.getTime() >=
AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY,
instrument: auditCInstrument(locale),
},
takeOver: [
{
field: "conditions",
freeTextPresent: profile?.conditionsEncrypted != null,
structuredCount: illnessCount,
available: illnessOn,
},
{
field: "allergies",
freeTextPresent: profile?.allergiesEncrypted != null,
structuredCount: allergyCount,
available: true,
},
],
};
}
// ── AI switches ──────────────────────────────────────────────────────────────
export type LifeAiSectionsOutcome =
| { conflict: true }
| {
conflict: false;
aiSections: Record<LifeAiSection, boolean>;
updatedAt: string;
};
function lifeSwitches(
stored: Set<StoredHealthProfileAiSection>,
): Record<LifeAiSection, boolean> {
return Object.fromEntries(
LIFE_AI_SECTIONS.map((section) => [section, stored.has(section)]),
) as Record<LifeAiSection, boolean>;
}
/**
* Switch life-circumstance sections in or out of the AI inclusion list. Only
* the named sections change; the other stored values, the five about-me
* sections among them, stay as they are. `base` is the about-me token
* (`UserHealthProfile.updatedAt`): a stale one is a conflict, as on about-me.
* Any change clears the cached briefing in the same transaction, because it
* may quote a section that just left.
*/
export async function updateLifeAiSections(
userId: string,
changes: Partial<Record<LifeAiSection, boolean>>,
base?: Date,
): Promise<LifeAiSectionsOutcome> {
return prisma.$transaction(async (tx) => {
const row = await tx.userHealthProfile.findUnique({
where: { userId },
select: { aiIncludedSections: true, updatedAt: true },
});
if (row && base && row.updatedAt.getTime() !== base.getTime()) {
return { conflict: true as const };
}
const next = storedSections(row?.aiIncludedSections);
for (const [section, on] of Object.entries(changes)) {
if (on) next.add(section as StoredHealthProfileAiSection);
else next.delete(section as StoredHealthProfileAiSection);
}
const aiIncludedSections = [...next];
const saved = row
? await tx.userHealthProfile.update({
where: { userId },
data: { aiIncludedSections },
select: { updatedAt: true },
})
: await tx.userHealthProfile.create({
data: { userId, aiIncludedSections },
select: { updatedAt: true },
});
await invalidateHealthProfileFactConsumers(userId, tx);
return {
conflict: false as const,
aiSections: lifeSwitches(next),
updatedAt: saved.updatedAt.toISOString(),
};
});
}
/** Store "still true" for one section: the instant only, never a revision. */
export async function confirmLifeSection(
userId: string,
section: LifeSectionKey,
now: Date = new Date(),
): Promise<string> {
const stamp = now.toISOString();
await prisma.$transaction(async (tx) => {
const row = await tx.userHealthProfile.findUnique({
where: { userId },
select: { lifeConfirmedJson: true },
});
const confirmedAt: Record<string, string> = {};
for (const [key, at] of confirmations(row?.lifeConfirmedJson)) {
if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) {
confirmedAt[key] = at.toISOString();
}
}
confirmedAt[section] = stamp;
await tx.userHealthProfile.upsert({
where: { userId },
create: { userId, lifeConfirmedJson: confirmedAt },
update: { lifeConfirmedJson: confirmedAt },
});
});
return stamp;
}
// ── AUDIT-C entries ──────────────────────────────────────────────────────────
/**
* The client's `externalId` is stored with this prefix. The screeners share
* the table and a per-user unique index on `external_id`, so a sync client
* that happened to reuse an id across the two surfaces would otherwise get
* the wrong record back as its "existing" entry.
*/
const AUDIT_C_EXTERNAL_ID_PREFIX = "audit-c:";
export interface AuditCCreateInput {
items: [number, number, number];
takenAt?: Date;
tz?: string;
locale: string;
source: "WEB" | "IOS";
externalId?: string;
}
const AUDIT_C_SELECT = {
id: true,
takenAt: true,
totalScore: true,
locale: true,
responsesEncrypted: true,
} as const;
/**
* Store and score one entry. A repeat `externalId` returns the stored entry
* (`replayed: true`) and writes nothing.
*/
export async function createAuditC(
userId: string,
input: AuditCCreateInput,
now: Date = new Date(),
): Promise<{ result: AuditCResult; replayed: boolean }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { gender: true, locale: true },
});
const sex = user?.gender ?? null;
const labelLocale = user?.locale ?? "en";
const externalId = input.externalId
? `${AUDIT_C_EXTERNAL_ID_PREFIX}${input.externalId}`
: null;
const replay = async () => {
if (!externalId) return null;
const existing = await prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", externalId },
select: AUDIT_C_SELECT,
});
if (!existing) return null;
const opened = openAuditCResponses(existing.responsesEncrypted);
return {
result: toAuditCResult(
existing,
opened?.items ?? input.items,
sex,
labelLocale,
),
replayed: true,
};
};
const prior = await replay();
if (prior) return prior;
const locale = normaliseAuditCLocale(input.locale);
const responses: AuditCResponses = { schema: 1, items: input.items };
try {
const row = await prisma.mentalHealthAssessment.create({
data: {
userId,
instrument: "AUDIT_C",
locale,
responsesEncrypted: encryptToBytes(JSON.stringify(responses)),
totalScore: auditCTotal(input.items),
// Not banded: the cut-off depends on the profile's sex at read time,
// so nothing derived from it is frozen into the row.
severityBand: "unbanded",
takenAt: input.takenAt ?? now,
tz: input.tz ?? null,
source: input.source,
externalId,
},
select: AUDIT_C_SELECT,
});
await invalidateHealthProfileFactConsumers(userId, prisma);
return {
result: toAuditCResult(row, input.items, sex, labelLocale),
replayed: false,
};
} catch (error) {
// A concurrent replay of the same externalId lost the unique race.
if (
externalId &&
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
const winner = await replay();
if (winner) return winner;
}
throw error;
}
}
// ── Clinical summary (doctor report, FHIR) ───────────────────────────────────
export async function loadLifeClinicalSummary(
userId: string,
now: Date = new Date(),
): Promise<LifeClinicalSummary> {
const [user, rows, audit] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { gender: true, locale: true },
}),
prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: {
in: [
"SMOKING_STATUS",
"TOBACCO_DETAIL",
"ACTIVITY_HABIT",
"WORK_CONTEXT",
],
},
validUntil: null,
supersededByRevisionId: null,
},
select: { kind: true, valueEncrypted: true },
}),
prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { id: true, takenAt: true, totalScore: true, locale: true },
}),
]);
const value = <T>(kind: string): T | null => {
const row = rows.find((candidate) => candidate.kind === kind);
if (!row) return null;
const opened =
kind === "SMOKING_STATUS"
? decryptHealthProfileFactValue("SMOKING_STATUS", row.valueEncrypted)
: decryptLifeFactValue(kind as LifeFactKind, row.valueEncrypted);
return (opened.value as T | null) ?? null;
};
const packYears = computePackYears(
value<string>("SMOKING_STATUS"),
value<TobaccoDetailValue>("TOBACCO_DETAIL"),
now,
);
const activity = value<ActivityHabitValue>("ACTIVITY_HABIT");
const work = value<WorkContextValue>("WORK_CONTEXT");
const cutoff = audit
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
: null;
return {
packYears: packYears.value,
auditC: audit
? {
id: audit.id,
takenAt: audit.takenAt.toISOString(),
totalScore: audit.totalScore,
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
cutoffValue: cutoff?.value ?? null,
cutoffSourceLabel: cutoff?.sourceLabel ?? null,
}
: null,
activityMinutesPerWeek: activityMinutesPerWeek(
activity?.moderateDaysPerWeek ?? null,
activity?.moderateMinutesPerDay ?? null,
).value,
sittingHoursWorkday: activity?.sittingHoursWorkday ?? null,
work: work
? {
situation: work.situation,
physicalNature: work.physicalNature,
nightWork: work.nightWork,
exposures: work.exposures,
}
: null,
};
}
+402
View File
@@ -0,0 +1,402 @@
/**
* v1.43 (#614) — the pure half of the life circumstances: which kinds belong
* to which section, and every number the read route derives from the answers
* (pack-years, activity minutes, free weekdays, the screening hints). The
* server resolves these once; the web and the app render them as delivered.
*
* Nothing here is ever inferred from anything but the person's own answers
* and the two profile columns it needs (birth date and sex): no smoking status
* from mood tags, no activity from steps. A missing input is an honest
* "missing", never a zero.
*
* Client-safe: no database, no crypto.
*/
import { countryLabel } from "@/lib/profile/country-label";
import type {
LifeAiSection,
LifeFactKind,
LifeSectionKey,
TobaccoDetailValue,
WorkContextValue,
} from "@/lib/validations/life-circumstances";
/** The kinds a section shows, in display order. Alcohol is AUDIT-C, not a fact. */
export const LIFE_SECTIONS: ReadonlyArray<{
key: LifeSectionKey;
aiSection: Exclude<LifeAiSection, "SHIFT_SCHEDULE">;
kinds: ReadonlyArray<"SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind>;
}> = [
{
key: "tobacco",
aiSection: "SMOKING_STATUS",
kinds: ["SMOKING_STATUS", "TOBACCO_DETAIL"],
},
{ key: "alcohol", aiSection: "ALCOHOL_PATTERN", kinds: [] },
{ key: "activity", aiSection: "ACTIVITY", kinds: ["ACTIVITY_HABIT"] },
{
key: "work",
aiSection: "WORK",
kinds: ["SHIFT_SCHEDULE", "WORK_CONTEXT", "FREE_WEEKDAYS"],
},
{ key: "habits", aiSection: "HABITS", kinds: ["HABITS"] },
{ key: "goals", aiSection: "GOALS", kinds: ["HEALTH_GOALS"] },
];
/**
* The AI section each life kind is released under. A kind reaches a prompt
* only when this section is in the profile's inclusion list; otherwise it is
* never even decrypted for one.
*/
export const LIFE_KIND_AI_SECTION: Readonly<
Record<LifeFactKind, LifeAiSection>
> = {
TOBACCO_DETAIL: "SMOKING_STATUS",
ACTIVITY_HABIT: "ACTIVITY",
WORK_CONTEXT: "WORK",
FREE_WEEKDAYS: "WORK",
HABITS: "HABITS",
HEALTH_GOALS: "GOALS",
};
/** A section asks "still true?" once its newest answer or confirmation is this old. */
export const REVIEW_AFTER_DAYS = 365;
const MS_PER_DAY = 86_400_000;
/** Whether an instant is at least `REVIEW_AFTER_DAYS` before `now`. */
export function isReviewDue(at: Date | null, now: Date): boolean {
return (
at !== null &&
now.getTime() - at.getTime() >= REVIEW_AFTER_DAYS * MS_PER_DAY
);
}
// ── Pack-years ───────────────────────────────────────────────────────────────
export interface PackYears {
value: number | null;
status: "computed" | "not_applicable" | "missing_inputs";
}
/**
* Cigarettes per day / 20 × years smoked, to one decimal. Years run from the
* start year to the quit year for a former smoker, to the current year
* otherwise. Only with both inputs; never estimated.
*/
export function computePackYears(
smokingStatus: string | null,
detail: TobaccoDetailValue | null,
now: Date,
): PackYears {
if (smokingStatus === "NEVER")
return { value: null, status: "not_applicable" };
if (
detail &&
detail.products.length > 0 &&
!detail.products.includes("cigarettes")
) {
return { value: null, status: "not_applicable" };
}
if (
!detail ||
detail.cigarettesPerDay === null ||
detail.startYear === null
) {
return { value: null, status: "missing_inputs" };
}
const quitYear = detail.quitMonth
? Number(detail.quitMonth.slice(0, 4))
: null;
if (smokingStatus === "FORMER" && quitYear === null) {
return { value: null, status: "missing_inputs" };
}
const endYear =
smokingStatus === "CURRENT" || quitYear === null
? now.getUTCFullYear()
: quitYear;
const years = endYear - detail.startYear;
if (years < 0) return { value: null, status: "missing_inputs" };
return {
value: Math.round((detail.cigarettesPerDay / 20) * years * 10) / 10,
status: "computed",
};
}
/** Whole years smoked, from the same inputs as pack-years; null when unknown. */
export function yearsSmoked(
smokingStatus: string | null,
detail: TobaccoDetailValue | null,
now: Date,
): number | null {
if (!detail || detail.startYear === null) return null;
const quitYear = detail.quitMonth
? Number(detail.quitMonth.slice(0, 4))
: null;
const endYear =
smokingStatus === "CURRENT" || quitYear === null
? now.getUTCFullYear()
: quitYear;
const years = endYear - detail.startYear;
return years >= 0 ? years : null;
}
// ── Activity ─────────────────────────────────────────────────────────────────
/** The Exercise Vital Sign product: days per week × minutes per day. */
export function activityMinutesPerWeek(
daysPerWeek: number | null,
minutesPerDay: number | null,
): { value: number | null; meetsWho150: boolean | null } {
if (daysPerWeek === null || minutesPerDay === null) {
return { value: null, meetsWho150: null };
}
const value = daysPerWeek * minutesPerDay;
return { value, meetsWho150: value >= 150 };
}
// ── Free weekdays ────────────────────────────────────────────────────────────
/** Saturday and Sunday, the calendar default when nobody said otherwise. */
export const DEFAULT_FREE_WEEKDAYS: readonly number[] = [6, 7];
/** One dated answer to "which weekdays are free": `[validFrom, validUntil)`. */
export interface FreeWeekdaysSpan {
validFrom: Date;
validUntil: Date | null;
days: readonly number[];
}
/**
* Day type of a night from its wake day (`YYYY-MM-DD`), read against the
* answer that was in force on that day. A night outside every answered span
* falls back to the weekend default, so a free-days answer given last month
* does not rewrite how last year's nights were read.
*/
export function dayTypeForNight(
night: string,
spans: readonly FreeWeekdaysSpan[],
): "work" | "free" {
const [y, m, d] = night.split("-").map(Number);
if (!y || !m || !d) return "work";
const at = Date.UTC(y, m - 1, d, 12);
const jsDay = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun
const isoDay = jsDay === 0 ? 7 : jsDay;
const span = spans.find(
(s) =>
s.validFrom.getTime() <= at &&
(s.validUntil === null || at < s.validUntil.getTime()),
);
const free = span ? span.days : DEFAULT_FREE_WEEKDAYS;
return free.includes(isoDay) ? "free" : "work";
}
// ── Screening hints ──────────────────────────────────────────────────────────
export type ScreeningRegion = "DE" | "US";
export interface ScreeningHint {
id: "lung_ldct" | "aaa_ultrasound";
status: "suggested" | "not_eligible" | "unknown";
reasonKeys: string[];
region: ScreeningRegion;
sourceLabel: string;
sourceUrl: string;
}
/** The region of the hints follows the account language: German gives the German rules. */
export function screeningRegion(
locale: string | null | undefined,
): ScreeningRegion {
return locale === "de" ? "DE" : "US";
}
const SOURCES = {
lung_ldct: {
DE: {
label:
"G-BA, Beschluss vom 18.06.2025 zur Lungenkrebs-Früherkennung mittels Niedrigdosis-CT",
url: "https://www.g-ba.de/presse/pressemitteilungen-meldungen/1263/",
},
US: {
label: "USPSTF, Lung Cancer: Screening (2021)",
url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/lung-cancer-screening",
},
},
aaa_ultrasound: {
DE: {
label:
"G-BA, Ultraschallscreening auf Bauchaortenaneurysmen (Beschluss vom 20.10.2016)",
url: "https://www.g-ba.de/institution/presse/pressemitteilungen/645/",
},
US: {
label: "USPSTF, Abdominal Aortic Aneurysm: Screening (2019)",
url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/abdominal-aortic-aneurysm-screening",
},
},
} as const;
export interface ScreeningInputs {
ageYears: number | null;
/** Profile sex as stored: MALE, FEMALE, OTHER or null. */
sex: string | null;
smokingStatus: string | null;
detail: TobaccoDetailValue | null;
packYears: PackYears;
locale: string | null | undefined;
now: Date;
}
function hint(
id: ScreeningHint["id"],
region: ScreeningRegion,
locale: string | null | undefined,
status: ScreeningHint["status"],
reasonKeys: string[],
): ScreeningHint {
const source = SOURCES[id][region];
return {
id,
status,
reasonKeys,
region,
sourceLabel: `${source.label} (${countryLabel(region, locale)})`,
sourceUrl: source.url,
};
}
/** Whole years since the quit month, or null for a current smoker or without one. */
function yearsSinceQuit(
detail: TobaccoDetailValue | null,
now: Date,
): number | null {
if (!detail?.quitMonth) return null;
const [y, m] = detail.quitMonth.split("-").map(Number);
const months = (now.getUTCFullYear() - y) * 12 + (now.getUTCMonth() + 1 - m);
return Math.max(0, Math.floor(months / 12));
}
/**
* Low-dose CT for lung cancer.
* DE (G-BA, 18.06.2025): 50 to 75 years, at least 25 years smoked, at least
* 15 pack-years, smoking now or stopped at most 10 years ago.
* US (USPSTF 2021): 50 to 80 years, at least 20 pack-years, smoking now or
* stopped within the past 15 years.
*/
function lungHint(
input: ScreeningInputs,
region: ScreeningRegion,
): ScreeningHint {
const rule =
region === "DE"
? { minAge: 50, maxAge: 75, minPackYears: 15, minYears: 25, maxQuit: 10 }
: {
minAge: 50,
maxAge: 80,
minPackYears: 20,
minYears: null,
maxQuit: 15,
};
if (input.smokingStatus === "NEVER") {
return hint("lung_ldct", region, input.locale, "not_eligible", [
"never_smoked",
]);
}
const failed: string[] = [];
const missing: string[] = [];
if (input.ageYears === null) missing.push("missing_birth_date");
else if (input.ageYears < rule.minAge || input.ageYears > rule.maxAge) {
failed.push("age_out_of_range");
}
if (input.smokingStatus === null) missing.push("missing_smoking_status");
if (input.packYears.status === "not_applicable") failed.push("no_cigarettes");
else if (input.packYears.value === null)
missing.push("missing_smoking_history");
else if (input.packYears.value < rule.minPackYears) {
failed.push(
region === "DE" ? "pack_years_below_15" : "pack_years_below_20",
);
}
if (rule.minYears !== null) {
const years = yearsSmoked(input.smokingStatus, input.detail, input.now);
if (years === null) {
if (!missing.includes("missing_smoking_history"))
missing.push("missing_smoking_history");
} else if (years < rule.minYears) failed.push("smoked_years_below_25");
}
if (input.smokingStatus === "FORMER") {
const since = yearsSinceQuit(input.detail, input.now);
if (since === null) missing.push("missing_quit_date");
else if (since > rule.maxQuit) {
failed.push(
region === "DE" ? "quit_over_10_years" : "quit_over_15_years",
);
}
}
if (failed.length > 0)
return hint("lung_ldct", region, input.locale, "not_eligible", failed);
if (missing.length > 0)
return hint("lung_ldct", region, input.locale, "unknown", missing);
return hint("lung_ldct", region, input.locale, "suggested", []);
}
/**
* Ultrasound for an abdominal aortic aneurysm.
* DE (G-BA): men from 65, once.
* US (USPSTF 2019): men 65 to 75 who have ever smoked.
*/
function aaaHint(
input: ScreeningInputs,
region: ScreeningRegion,
): ScreeningHint {
const failed: string[] = [];
const missing: string[] = [];
if (input.sex === null) missing.push("missing_sex");
else if (input.sex !== "MALE") failed.push("sex_not_male");
if (input.ageYears === null) missing.push("missing_birth_date");
else if (input.ageYears < 65 || (region === "US" && input.ageYears > 75)) {
failed.push("age_out_of_range");
}
if (region === "US") {
if (input.smokingStatus === "NEVER") failed.push("never_smoked");
else if (input.smokingStatus === null)
missing.push("missing_smoking_status");
}
if (failed.length > 0)
return hint("aaa_ultrasound", region, input.locale, "not_eligible", failed);
if (missing.length > 0)
return hint("aaa_ultrasound", region, input.locale, "unknown", missing);
return hint("aaa_ultrasound", region, input.locale, "suggested", []);
}
/** Both hints, always in this order. A hint, never a reminder: nothing is scheduled. */
export function screeningHints(input: ScreeningInputs): ScreeningHint[] {
const region = screeningRegion(input.locale);
return [lungHint(input, region), aaaHint(input, region)];
}
// ── Clinical summary ─────────────────────────────────────────────────────────
/**
* What the doctor report and the FHIR export carry from the life
* circumstances: pack-years, the newest AUDIT-C as a screening result,
* activity and work. Diet and goals are deliberately absent (coach-only by
* design): they are context for a conversation, not clinical findings.
*/
export interface LifeClinicalSummary {
packYears: number | null;
auditC: {
id: string;
takenAt: string;
totalScore: number;
aboveCutoff: boolean | null;
cutoffValue: number | null;
cutoffSourceLabel: string | null;
} | null;
activityMinutesPerWeek: number | null;
sittingHoursWorkday: number | null;
work: {
situation: WorkContextValue["situation"];
physicalNature: WorkContextValue["physicalNature"];
nightWork: boolean | null;
exposures: WorkContextValue["exposures"];
} | null;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* v1.43 (#614) — the life-circumstance rows of the anamnesis section, one
* builder for the PDF and the clinician share view so both print the same
* lines. Self-reported values, labelled as such; AUDIT-C as a screening
* result with its cut-off and source, never as a finding. Diet and goals are
* not here on purpose (coach-only by design).
*
* Client-safe: pure, takes the translator and formatters it renders with.
*/
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
export function lifeReportRows(
life: LifeClinicalSummary | undefined,
t: Translate,
fmtNum: (value: number) => string,
fmtDate: (iso: string) => string,
): Array<[string, string]> {
if (!life) return [];
const absent = t("doctorReport.anamnesisNotRecorded");
const rows: Array<[string, string]> = [];
rows.push([
t("doctorReport.lifePackYears"),
life.packYears === null ? absent : fmtNum(life.packYears),
]);
if (life.auditC) {
const a = life.auditC;
const score = t("doctorReport.lifeAuditCScore", {
score: a.totalScore,
date: fmtDate(a.takenAt),
});
const verdict =
a.aboveCutoff === null || a.cutoffValue === null
? t("doctorReport.lifeAuditCNoCutoff")
: t(
a.aboveCutoff
? "doctorReport.lifeAuditCAbove"
: "doctorReport.lifeAuditCBelow",
{ cutoff: a.cutoffValue, source: a.cutoffSourceLabel ?? "" },
);
rows.push([t("doctorReport.lifeAuditC"), `${score} ${verdict}`]);
} else {
rows.push([t("doctorReport.lifeAuditC"), absent]);
}
rows.push([
t("doctorReport.lifeActivity"),
life.activityMinutesPerWeek === null
? absent
: t("doctorReport.lifeActivityValue", {
minutes: fmtNum(life.activityMinutesPerWeek),
}),
]);
rows.push([
t("doctorReport.lifeSitting"),
life.sittingHoursWorkday === null
? absent
: t("doctorReport.lifeSittingValue", {
hours: fmtNum(life.sittingHoursWorkday),
}),
]);
const work = life.work;
const workParts: string[] = [];
if (work?.situation) {
workParts.push(t(`records.life.values.situation.${work.situation}`));
}
if (work?.physicalNature) {
workParts.push(
t(`records.life.values.physicalNature.${work.physicalNature}`),
);
}
if (work?.nightWork === true) workParts.push(t("doctorReport.lifeNightWork"));
if (work && work.exposures.length > 0) {
workParts.push(
t("doctorReport.lifeExposures", {
list: work.exposures
.map((exposure) => t(`records.life.values.exposures.${exposure}`))
.join(", "),
}),
);
}
rows.push([
t("doctorReport.lifeWork"),
workParts.length > 0 ? workParts.join(", ") : absent,
]);
return rows;
}
+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). */
+4
View File
@@ -70,6 +70,10 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.allergyCreate", { name });
case "family-history.create":
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "anamnesis.audit_c.create":
return t("recordSharing.activityVerb.auditCCreate", { name });
case "anamnesis.audit_c.delete":
return t("recordSharing.activityVerb.auditCDelete", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "symptoms.event.create":
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
HEALTH_PROFILE_AI_SECTIONS,
removedHealthProfileFactSchema,
} from "../health-profile-facts";
import { removedFactSchema as removedHealthProfileFactSchema } from "../life-circumstances";
describe("health profile AI inclusion defaults", () => {
it("keeps the legacy opt-in list independent from accepted section values", () => {
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
auditCRequestSchema,
factWriteSchema,
isStoredFactPlaintext,
lifeAiSectionsPatchSchema,
} from "../life-circumstances";
const tobacco = {
schema: 1,
products: ["cigarettes"],
cigarettesPerDay: 20,
startYear: 1990,
quitMonth: "2020-06",
secondhandSmoke: null,
};
describe("fact writes (v1.43)", () => {
it("still takes the three closed kinds with their string value", () => {
expect(
factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "FORMER" })
.success,
).toBe(true);
expect(
factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "ROTATING" })
.success,
).toBe(false);
});
it("takes a life kind with its object value and an optional day", () => {
const parsed = factWriteSchema.safeParse({
kind: "TOBACCO_DETAIL",
value: tobacco,
validFrom: "2026-03-01",
});
expect(parsed.success).toBe(true);
});
it("refuses cigarettes per day without cigarettes, and unknown keys", () => {
expect(
factWriteSchema.safeParse({
kind: "TOBACCO_DETAIL",
value: { ...tobacco, products: ["e_cigarette"] },
}).success,
).toBe(false);
expect(
factWriteSchema.safeParse({
kind: "TOBACCO_DETAIL",
value: { ...tobacco, packYears: 30 },
}).success,
).toBe(false);
});
it("caps the goals at three and the sitting hours at half-hour steps", () => {
expect(
factWriteSchema.safeParse({
kind: "HEALTH_GOALS",
value: {
schema: 1,
goals: ["sleep", "weight", "fitness", "blood_sugar"],
},
}).success,
).toBe(false);
expect(
factWriteSchema.safeParse({
kind: "ACTIVITY_HABIT",
value: {
schema: 1,
moderateDaysPerWeek: 3,
moderateMinutesPerDay: 30,
sittingHoursWorkday: 7.25,
},
}).success,
).toBe(false);
});
it("checks a backup's plaintext the way a write checks the value", () => {
expect(isStoredFactPlaintext("SHIFT_SCHEDULE", "ROTATING")).toBe(true);
expect(
isStoredFactPlaintext("TOBACCO_DETAIL", JSON.stringify(tobacco)),
).toBe(true);
expect(isStoredFactPlaintext("TOBACCO_DETAIL", "{")).toBe(false);
expect(isStoredFactPlaintext("HABITS", JSON.stringify(tobacco))).toBe(
false,
);
});
});
describe("AI switches and AUDIT-C requests", () => {
it("needs at least one section", () => {
expect(lifeAiSectionsPatchSchema.safeParse({ sections: {} }).success).toBe(
false,
);
expect(
lifeAiSectionsPatchSchema.safeParse({ sections: { HABITS: true } })
.success,
).toBe(true);
});
it("takes three items from 0 to 4", () => {
expect(
auditCRequestSchema.safeParse({ items: [0, 4, 2], source: "WEB" })
.success,
).toBe(true);
expect(
auditCRequestSchema.safeParse({ items: [0, 5, 2], source: "WEB" })
.success,
).toBe(false);
expect(
auditCRequestSchema.safeParse({ items: [0, 1], source: "WEB" }).success,
).toBe(false);
});
});
+10 -6
View File
@@ -77,10 +77,12 @@ import {
} from "@/generated/prisma/enums";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
healthProfileAiSectionSchema,
healthProfileFactKindSchema,
isHealthProfileFactValue,
storedHealthProfileAiSectionSchema,
} from "@/lib/validations/health-profile-facts";
import {
STORED_FACT_KINDS,
isStoredFactPlaintext,
} from "@/lib/validations/life-circumstances";
import {
advanceDirectiveStatusSchema,
emergencyBloodTypeSchema,
@@ -830,8 +832,10 @@ const healthProfileBackupSchema = z
conditions: z.string().nullable().default(null),
allergies: z.string().nullable().default(null),
coachFocus: z.string().nullable().default(null),
// Every stored section, the four life-circumstance ones included (v1.43):
// a file written by an account that opted one in must restore it opted in.
aiIncludedSections: z
.array(healthProfileAiSectionSchema)
.array(storedHealthProfileAiSectionSchema)
.default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]),
// v1.43 (#614) — section confirmations, section key to ISO instant.
// Carried as stored; the read route judges each stamp, so an unreadable
@@ -867,7 +871,7 @@ const healthProfileBackupSchema = z
const healthProfileFactBackupSchema = z
.object({
id: z.string().min(1),
kind: healthProfileFactKindSchema,
kind: z.enum(STORED_FACT_KINDS),
value: z.string().nullable().default(null),
valueEncrypted: base64BytesSchema.optional(),
validFrom: isoDateTime,
@@ -881,7 +885,7 @@ const healthProfileFactBackupSchema = z
if (fact.valueEncrypted === undefined) {
if (
fact.value === null ||
!isHealthProfileFactValue(fact.kind, fact.value)
!isStoredFactPlaintext(fact.kind, fact.value)
) {
ctx.addIssue({
code: "custom",
+30 -25
View File
@@ -27,6 +27,36 @@ export const DEFAULT_HEALTH_PROFILE_AI_SECTIONS = [
export const healthProfileAiSectionSchema = z.enum(HEALTH_PROFILE_AI_SECTIONS);
/**
* Every value the `ai_included_sections` column can hold: the eight above and
* the four life-circumstance sections (v1.43, #614). The eight-value schema is
* the wire form of `/api/coach/about-me`, which older app builds decode
* closed; this one is for the places that carry the column as stored, the
* backup file first among them, where dropping a value would silently take an
* opt-in back on restore.
*/
export const STORED_HEALTH_PROFILE_AI_SECTIONS = [
...HEALTH_PROFILE_AI_SECTIONS,
"ACTIVITY",
"WORK",
"HABITS",
"GOALS",
] as const;
export type StoredHealthProfileAiSection =
(typeof STORED_HEALTH_PROFILE_AI_SECTIONS)[number];
export const storedHealthProfileAiSectionSchema = z.enum(
STORED_HEALTH_PROFILE_AI_SECTIONS,
);
/** Narrow a stored section list to the eight the about-me wire carries. */
export function aboutMeAiSections(
stored: readonly string[],
): HealthProfileAiSection[] {
return stored.filter((section): section is HealthProfileAiSection =>
(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(section),
);
}
export const SMOKING_STATUS_VALUES = ["NEVER", "FORMER", "CURRENT"] as const;
export const ALCOHOL_PATTERN_VALUES = [
"NONE",
@@ -74,25 +104,6 @@ export function isHealthProfileFactValue(
return HEALTH_PROFILE_FACT_VALUES[kind][value] === true;
}
export const healthProfileFactWriteSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("SMOKING_STATUS"),
value: z.enum(SMOKING_STATUS_VALUES),
}),
z.object({
kind: z.literal("ALCOHOL_PATTERN"),
value: z.enum(ALCOHOL_PATTERN_VALUES),
}),
z.object({
kind: z.literal("SHIFT_SCHEDULE"),
value: z.enum(SHIFT_SCHEDULE_VALUES),
}),
]);
export const healthProfileFactCorrectionSchema = z.object({
value: z.string().trim().min(1),
});
export function validateCorrectedFactValue(
kind: HealthProfileFactKind,
value: string,
@@ -120,9 +131,3 @@ export const healthProfileFactsResponseSchema = z.object({
}),
history: z.array(healthProfileFactDtoSchema),
});
export const removedHealthProfileFactSchema = z.object({
id: z.string(),
kind: healthProfileFactKindSchema,
removedAt: z.iso.datetime({ offset: true }),
});
+166
View File
@@ -11,6 +11,13 @@
import { z } from "zod/v4";
import { assertStableExternalId } from "@/lib/validations/external-id";
import {
ALCOHOL_PATTERN_VALUES,
HEALTH_PROFILE_FACT_KINDS,
SHIFT_SCHEDULE_VALUES,
SMOKING_STATUS_VALUES,
isHealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
// ── Fact kinds and their values ──────────────────────────────────────────────
@@ -219,3 +226,162 @@ export const AUDIT_C_CUTOFF_SOURCES = [
"KNHANES_KR",
"BRADLEY_2007",
] as const;
// ── Writing facts through `/api/anamnesis/facts` ─────────────────────────────
/** Every kind a fact revision row can carry: the three closed ones and the six above. */
export const STORED_FACT_KINDS = [
...HEALTH_PROFILE_FACT_KINDS,
...LIFE_FACT_KINDS,
] as const;
export type StoredFactKind = (typeof STORED_FACT_KINDS)[number];
export function isLifeFactKind(kind: string): kind is LifeFactKind {
return (LIFE_FACT_KINDS as readonly string[]).includes(kind);
}
/**
* "Valid since", a calendar day. Optional: without it the answer holds from
* the moment it is saved. The web form sends the first of a month, because a
* month is as precise as anyone remembers when they stopped smoking.
*/
const validFromDay = z.iso
.date()
.optional()
.describe(
"The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.",
);
/**
* `POST /api/anamnesis/facts`. The three closed kinds keep their string value
* (older app builds send exactly that); the six life-circumstance kinds send
* their object value with `schema: 1`.
*/
export const factWriteSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("SMOKING_STATUS"),
value: z.enum(SMOKING_STATUS_VALUES),
validFrom: validFromDay,
}),
z.object({
kind: z.literal("ALCOHOL_PATTERN"),
value: z.enum(ALCOHOL_PATTERN_VALUES),
validFrom: validFromDay,
}),
z.object({
kind: z.literal("SHIFT_SCHEDULE"),
value: z.enum(SHIFT_SCHEDULE_VALUES),
validFrom: validFromDay,
}),
z.object({
kind: z.literal("TOBACCO_DETAIL"),
value: tobaccoDetailValueSchema,
validFrom: validFromDay,
}),
z.object({
kind: z.literal("ACTIVITY_HABIT"),
value: activityHabitValueSchema,
validFrom: validFromDay,
}),
z.object({
kind: z.literal("WORK_CONTEXT"),
value: workContextValueSchema,
validFrom: validFromDay,
}),
z.object({
kind: z.literal("FREE_WEEKDAYS"),
value: freeWeekdaysValueSchema,
validFrom: validFromDay,
}),
z.object({
kind: z.literal("HABITS"),
value: habitsValueSchema,
validFrom: validFromDay,
}),
z.object({
kind: z.literal("HEALTH_GOALS"),
value: healthGoalsValueSchema,
validFrom: validFromDay,
}),
]);
export type FactWriteInput = z.infer<typeof factWriteSchema>;
/**
* `PATCH /api/anamnesis/facts/{id}`: the closed kinds take a string, the life
* kinds an object; which one the row needs is checked against the stored
* kind after the row is found.
*/
export const factCorrectionSchema = z.object({
value: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]),
validFrom: validFromDay,
});
/** Parse a life-circumstance value against its kind's schema; null when it does not fit. */
export function parseLifeFactValue(
kind: LifeFactKind,
value: unknown,
): LifeFactValue | null {
const parsed = LIFE_FACT_VALUE_SCHEMAS[kind].safeParse(value);
return parsed.success ? (parsed.data as LifeFactValue) : null;
}
export type TobaccoDetailValue = z.infer<typeof tobaccoDetailValueSchema>;
export type ActivityHabitValue = z.infer<typeof activityHabitValueSchema>;
export type WorkContextValue = z.infer<typeof workContextValueSchema>;
export type FreeWeekdaysValue = z.infer<typeof freeWeekdaysValueSchema>;
export type HabitsValue = z.infer<typeof habitsValueSchema>;
export type HealthGoalsValue = z.infer<typeof healthGoalsValueSchema>;
export type LifeFactValue =
| TobaccoDetailValue
| ActivityHabitValue
| WorkContextValue
| FreeWeekdaysValue
| HabitsValue
| HealthGoalsValue;
/** `DELETE /api/anamnesis/facts/{id}`: the revision whose interval was closed. */
export const removedFactSchema = z.object({
id: z.string(),
kind: z.enum(STORED_FACT_KINDS),
removedAt: z.iso.datetime({ offset: true }),
});
/**
* A life-circumstance revision as `/api/anamnesis/life-circumstances` and the
* fact writes answer it. `SMOKING_STATUS` and `SHIFT_SCHEDULE` ride along in
* their sections with their string value.
*/
export const lifeFactRevisionSchema = z.object({
id: z.string(),
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
value: z
.unknown()
.describe(
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
),
unreadable: z
.boolean()
.describe("The sealed value could not be opened on this host."),
validFrom: z.iso.datetime({ offset: true }),
validUntil: z.null(),
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
createdAt: z.iso.datetime({ offset: true }),
});
/**
* Whether `raw` is a valid plaintext for a stored fact of `kind`: one of the
* closed values for the three original kinds, the JSON of a value that fits
* its schema for the life kinds. The portable backup carries exactly this
* plaintext, so its writer and its restore share this one check.
*/
export function isStoredFactPlaintext(
kind: StoredFactKind,
raw: string,
): boolean {
if (!isLifeFactKind(kind)) return isHealthProfileFactValue(kind, raw);
try {
return parseLifeFactValue(kind, JSON.parse(raw)) !== null;
} catch {
return false;
}
}
+16
View File
@@ -570,6 +570,22 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"delete",
),
handler(
"app/api/anamnesis/audit-c/route.ts",
"/api/anamnesis/audit-c",
"POST",
"profile",
"manage",
"create",
),
handler(
"app/api/anamnesis/audit-c/[id]/route.ts",
"/api/anamnesis/audit-c/[id]",
"DELETE",
"profile",
"manage",
"delete",
),
handler(
"app/api/medications/[id]/ingredients/route.ts",
"/api/medications/[id]/ingredients",
@@ -8,6 +8,7 @@ process.env.ENCRYPTION_KEY =
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
import { parseBackupPayload } from "@/lib/validations/backup";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -172,4 +173,130 @@ describe("health profile disaster-recovery round trip", () => {
await prisma.userHealthProfile.count({ where: { userId: user.id } }),
).toBe(0);
});
it("carries the life circumstances both ways: new kinds, new AI sections, AUDIT-C (v1.43)", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "life-roundtrip",
email: "life-roundtrip@example.test",
role: "USER",
},
});
await prisma.userHealthProfile.create({
data: {
userId: user.id,
aiIncludedSections: ["ABOUT_ME", "HABITS", "GOALS"],
lifeConfirmedJson: { work: "2026-09-01T10:00:00.000Z" },
},
});
const goals = { schema: 1, goals: ["sleep", "fitness"] };
const tobacco = {
schema: 1,
products: ["cigarettes"],
cigarettesPerDay: 10,
startYear: 2001,
quitMonth: null,
secondhandSmoke: null,
};
await prisma.healthProfileFactRevision.createMany({
data: [
{
id: "goals-current",
userId: user.id,
kind: "HEALTH_GOALS",
valueEncrypted: encryptToBytes(JSON.stringify(goals)),
validFrom: new Date("2026-08-01T00:00:00.000Z"),
},
{
id: "tobacco-current",
userId: user.id,
kind: "TOBACCO_DETAIL",
valueEncrypted: encryptToBytes(JSON.stringify(tobacco)),
validFrom: new Date("2026-08-01T00:00:00.000Z"),
},
],
});
await prisma.mentalHealthAssessment.create({
data: {
id: "audit-c-entry",
userId: user.id,
instrument: "AUDIT_C",
locale: "de",
responsesEncrypted: encryptToBytes(
JSON.stringify({ schema: 1, items: [2, 1, 1] }),
),
totalScore: 4,
severityBand: "unbanded",
takenAt: new Date("2026-09-15T08:00:00.000Z"),
},
});
// Disaster recovery: everything, the AUDIT-C entry included.
const dr = parseBackupPayload(
JSON.parse(
JSON.stringify(
(
await buildFullBackupPayload(prisma, user.id, {
purpose: "disaster-recovery",
})
).payload,
),
),
);
expect(dr.healthProfile?.aiIncludedSections).toEqual([
"ABOUT_ME",
"HABITS",
"GOALS",
]);
expect(dr.healthProfileFacts.map((f) => f.kind).sort()).toEqual([
"HEALTH_GOALS",
"TOBACCO_DETAIL",
]);
expect(dr.mentalHealthAssessments.map((a) => a.instrument)).toEqual([
"AUDIT_C",
]);
await prisma.$transaction(async (tx) => {
await restoreProfileData(tx, user.id, dr);
await restoreSensitiveData(tx, user.id, dr);
});
const restored = await prisma.userHealthProfile.findUniqueOrThrow({
where: { userId: user.id },
});
expect(restored.aiIncludedSections).toEqual([
"ABOUT_ME",
"HABITS",
"GOALS",
]);
expect(restored.lifeConfirmedJson).toEqual({
work: "2026-09-01T10:00:00.000Z",
});
const audit = await prisma.mentalHealthAssessment.findUniqueOrThrow({
where: { id: "audit-c-entry" },
});
expect(audit.instrument).toBe("AUDIT_C");
expect(audit.totalScore).toBe(4);
// Portable: the values travel readable and come back sealed and valid.
const portable = parseBackupPayload(
JSON.parse(
JSON.stringify((await buildFullBackupPayload(prisma, user.id)).payload),
),
);
const goalsEntry = portable.healthProfileFacts.find(
(f) => f.kind === "HEALTH_GOALS",
);
expect(JSON.parse(goalsEntry!.value!)).toEqual(goals);
await prisma.$transaction((tx) =>
restoreProfileData(tx, user.id, portable),
);
const { decryptLifeFactValue } = await import("@/lib/profile/health-facts");
const row = await prisma.healthProfileFactRevision.findFirstOrThrow({
where: { userId: user.id, kind: "TOBACCO_DETAIL" },
});
expect(
decryptLifeFactValue("TOBACCO_DETAIL", row.valueEncrypted).value,
).toEqual(tobacco);
});
});
@@ -0,0 +1,523 @@
/**
* v1.43 (#614) — the life circumstances through the real routes against real
* Postgres: every new fact kind written, corrected and removed and read back
* through `GET /api/anamnesis/life-circumstances`; the closed legacy lists
* (`GET /api/anamnesis/facts`, `GET /api/mental-health/assessments`) never
* showing a new kind or AUDIT-C; the AUDIT-C cut-off from the entry's
* language and the profile's sex; the AI switches leaving the about-me wire
* at its eight values; and the sealed values never sitting in the clear.
*/
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
type Handler = (request: Request, ctx?: unknown) => Promise<Response>;
let counter = 0;
async function makeUser(overrides: Record<string, unknown> = {}) {
const suffix = `${counter++}`;
return getPrismaClient().user.create({
data: {
username: `life-${suffix}`,
email: `life-${suffix}@example.test`,
role: "USER",
timezone: "UTC",
locale: "en",
onboardingCompletedAt: new Date(),
modulePreferencesJson: { mentalHealth: true },
...overrides,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
}
function request(method: string, path: string, body?: unknown): Request {
return new Request(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function data<T>(response: Response, status = 200): Promise<T> {
const text = await response.text();
expect(response.status, text).toBe(status);
return (JSON.parse(text) as { data: T }).data;
}
async function postFact(body: unknown): Promise<Response> {
const { POST } = await import("@/app/api/anamnesis/facts/route");
return (POST as unknown as Handler)(
request("POST", "/api/anamnesis/facts", body),
);
}
async function patchFact(id: string, body: unknown): Promise<Response> {
const { PATCH } = await import("@/app/api/anamnesis/facts/[id]/route");
return (PATCH as unknown as Handler)(
request("PATCH", `/api/anamnesis/facts/${id}`, body),
{ params: Promise.resolve({ id }) },
);
}
async function deleteFact(id: string): Promise<Response> {
const { DELETE } = await import("@/app/api/anamnesis/facts/[id]/route");
return (DELETE as unknown as Handler)(
request("DELETE", `/api/anamnesis/facts/${id}`),
{ params: Promise.resolve({ id }) },
);
}
interface Life {
sections: Array<{
key: string;
aiIncluded: boolean;
lastConfirmedAt: string | null;
facts: Array<{ id: string; kind: string; value: unknown }>;
}>;
derived: {
packYears: { value: number | null; status: string };
activityMinutesPerWeek: {
value: number | null;
meetsWho150: boolean | null;
};
freeWeekdays: { days: number[]; source: string };
};
auditC: {
latest: {
id: string;
totalScore: number;
cutoff: { value: number; source: string } | null;
aboveCutoff: boolean | null;
} | null;
history: Array<{ id: string }>;
offerDue: boolean;
instrument: { locale: string; validatedLocale: boolean };
};
}
async function readLife(): Promise<Life> {
const { GET } = await import("@/app/api/anamnesis/life-circumstances/route");
return data<Life>(await (GET as unknown as () => Promise<Response>)());
}
function factOf(life: Life, kind: string) {
return life.sections.flatMap((s) => s.facts).find((f) => f.kind === kind);
}
const VALUES = {
TOBACCO_DETAIL: {
schema: 1,
products: ["cigarettes"],
cigarettesPerDay: 20,
startYear: 1990,
quitMonth: "2020-06",
secondhandSmoke: false,
},
ACTIVITY_HABIT: {
schema: 1,
moderateDaysPerWeek: 3,
moderateMinutesPerDay: 40,
sittingHoursWorkday: 7.5,
},
WORK_CONTEXT: {
schema: 1,
situation: "outdoor",
physicalNature: "heavy_physical",
nightWork: null,
exposures: ["heat", "dust"],
},
FREE_WEEKDAYS: { schema: 1, days: [2, 3] },
HABITS: {
schema: 1,
napRegularly: true,
snoringReported: null,
dietPattern: "vegetarian",
dietSpecifics: ["low_carb"],
lateMealsPerWeek: 2,
fruitVegPortionsPerDay: 4,
caffeineDrinksPerDay: 3,
latestCaffeine: "15_18",
},
HEALTH_GOALS: { schema: 1, goals: ["sleep", "fitness"] },
} as const;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
counter = 0;
});
describe("life-circumstance facts", () => {
it("writes, corrects and removes every new kind, and the closed list never shows one", async () => {
const user = await makeUser();
await signIn(user.id);
await data(
await postFact({ kind: "SMOKING_STATUS", value: "FORMER" }),
201,
);
for (const [kind, value] of Object.entries(VALUES)) {
await data(await postFact({ kind, value }), 201);
}
let life = await readLife();
for (const [kind, value] of Object.entries(VALUES)) {
expect(factOf(life, kind)?.value, kind).toEqual(value);
}
expect(life.sections.map((s) => s.key)).toEqual([
"tobacco",
"alcohol",
"activity",
"work",
"habits",
"goals",
]);
expect(life.derived.packYears).toEqual({ value: 30, status: "computed" });
expect(life.derived.activityMinutesPerWeek).toMatchObject({
value: 120,
meetsWho150: false,
});
expect(life.derived.freeWeekdays).toEqual({ days: [2, 3], source: "fact" });
// A second current answer of the same kind is the existing 409.
expect(
(await postFact({ kind: "HABITS", value: VALUES.HABITS })).status,
).toBe(409);
// Correct: a dated successor with the new value.
const goals = factOf(life, "HEALTH_GOALS")!;
const corrected = await data<{ id: string; value: unknown }>(
await patchFact(goals.id, {
value: { schema: 1, goals: ["quit_smoking"] },
validFrom: "2026-01-01",
}),
);
expect(corrected.value).toEqual({ schema: 1, goals: ["quit_smoking"] });
// A string for an object kind is the existing 422.
expect((await patchFact(corrected.id, { value: "SLEEP" })).status).toBe(
422,
);
// Remove: the interval closes; the kind reads as not recorded.
const habits = factOf(life, "HABITS")!;
await data(await deleteFact(habits.id));
life = await readLife();
expect(factOf(life, "HABITS")).toBeUndefined();
expect(factOf(life, "HEALTH_GOALS")?.value).toEqual({
schema: 1,
goals: ["quit_smoking"],
});
// The closed list: three kinds, nothing else, in `current` and `history`.
const { GET } = await import("@/app/api/anamnesis/facts/route");
const legacy = await data<{
current: Record<string, unknown>;
history: Array<{ kind: string }>;
}>(await (GET as unknown as () => Promise<Response>)());
expect(Object.keys(legacy.current).sort()).toEqual([
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
"SMOKING_STATUS",
]);
expect(
legacy.history.every((row) =>
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"].includes(
row.kind,
),
),
).toBe(true);
expect(legacy.history).toHaveLength(1);
});
it("seals the values: no answer sits in the clear", async () => {
const user = await makeUser();
await signIn(user.id);
await data(
await postFact({ kind: "WORK_CONTEXT", value: VALUES.WORK_CONTEXT }),
201,
);
const row =
await getPrismaClient().healthProfileFactRevision.findFirstOrThrow({
where: { userId: user.id, kind: "WORK_CONTEXT" },
});
const raw = Buffer.from(row.valueEncrypted).toString("latin1");
expect(raw).not.toContain("outdoor");
expect(raw).not.toContain("heavy_physical");
});
it("dates an answer back to the month given", async () => {
const user = await makeUser();
await signIn(user.id);
const created = await data<{ validFrom: string }>(
await postFact({
kind: "FREE_WEEKDAYS",
value: VALUES.FREE_WEEKDAYS,
validFrom: "2025-04-01",
}),
201,
);
expect(created.validFrom).toBe("2025-04-01T12:00:00.000Z");
});
});
describe("AUDIT-C", () => {
async function postAuditC(body: unknown): Promise<Response> {
const { POST } = await import("@/app/api/anamnesis/audit-c/route");
return (POST as unknown as Handler)(
request("POST", "/api/anamnesis/audit-c", body),
);
}
it("scores a German entry of a woman with sum 4 at the S3 cut-off, and none without a sex", async () => {
const woman = await makeUser({ locale: "de", gender: "FEMALE" });
await signIn(woman.id);
const { result } = await data<{
result: {
totalScore: number;
aboveCutoff: boolean | null;
cutoff: { value: number; source: string; sourceLabel: string } | null;
validatedLocale: boolean;
};
}>(
await postAuditC({ items: [2, 1, 1], locale: "de", source: "WEB" }),
201,
);
expect(result.totalScore).toBe(4);
expect(result.aboveCutoff).toBe(true);
expect(result.cutoff).toMatchObject({ value: 4, source: "S3_ALCOHOL_DE" });
expect(result.validatedLocale).toBe(true);
const nobody = await makeUser({ locale: "de", gender: null });
await signIn(nobody.id);
const unsexed = await data<{
result: { cutoff: unknown; aboveCutoff: unknown };
}>(
await postAuditC({ items: [2, 1, 1], locale: "de", source: "WEB" }),
201,
);
expect(unsexed.result.cutoff).toBeNull();
expect(unsexed.result.aboveCutoff).toBeNull();
});
it("replays one externalId to the same entry and never lists it among the screeners", async () => {
const user = await makeUser({ locale: "fr", gender: "MALE" });
await signIn(user.id);
const body = {
items: [1, 0, 0],
source: "IOS",
externalId: "outbox-audit-1",
};
const first = await data<{ result: { id: string } }>(
await postAuditC(body),
201,
);
const again = await data<{ result: { id: string } }>(
await postAuditC(body),
200,
);
expect(again.result.id).toBe(first.result.id);
// A screener with the same client id is its own record, not this one.
const { POST: postScreener, GET: listScreeners } =
await import("@/app/api/mental-health/assessments/route");
const screener = await (postScreener as unknown as Handler)(
request("POST", "/api/mental-health/assessments", {
instrument: "WHO5",
items: [3, 3, 3, 3, 3],
source: "IOS",
externalId: "outbox-audit-1",
}),
);
expect(screener.status).toBe(201);
const { NextRequest } = await import("next/server");
const list = await data<{ assessments: Array<{ instrument: string }> }>(
await (listScreeners as unknown as (r: Request) => Promise<Response>)(
new NextRequest("http://localhost/api/mental-health/assessments"),
),
);
expect(list.assessments.map((a) => a.instrument)).toEqual(["WHO5"]);
// The detail route answers 404 for an AUDIT-C id.
const { GET: detail } =
await import("@/app/api/mental-health/assessments/[id]/route");
const byId = await (detail as unknown as Handler)(
new NextRequest(
`http://localhost/api/mental-health/assessments/${first.result.id}`,
),
{ params: Promise.resolve({ id: first.result.id }) },
);
expect(byId.status).toBe(404);
// The read route carries it, with the English items for French.
const life = await readLife();
expect(life.auditC.latest?.id).toBe(first.result.id);
expect(life.auditC.offerDue).toBe(false);
expect(life.auditC.instrument).toMatchObject({
locale: "en",
validatedLocale: false,
});
});
it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => {
const user = await makeUser();
await signIn(user.id);
const { result } = await data<{ result: { id: string } }>(
await postAuditC({ items: [0, 0, 0], source: "WEB" }),
201,
);
const { DELETE } = await import("@/app/api/anamnesis/audit-c/[id]/route");
const remove = (id: string) =>
(DELETE as unknown as Handler)(
request("DELETE", `/api/anamnesis/audit-c/${id}`),
{ params: Promise.resolve({ id }) },
);
const removed = await data<{ id: string; removedAt: string }>(
await remove(result.id),
);
expect(removed.id).toBe(result.id);
const again = await remove(result.id);
expect(again.status).toBe(404);
expect(
((await again.json()) as { error: { code?: string }; meta?: unknown })
.error,
).toBeTruthy();
const row =
await getPrismaClient().mentalHealthAssessment.findUniqueOrThrow({
where: { id: result.id },
});
expect(row.deletedAt).not.toBeNull();
expect((await readLife()).auditC.latest).toBeNull();
});
});
describe("AI switches and confirmations", () => {
it("switches a life section without showing it on the about-me wire, and a PUT there keeps it", async () => {
const user = await makeUser({
modulePreferencesJson: { mentalHealth: true, coach: true },
});
await signIn(user.id);
const { PATCH } =
await import("@/app/api/anamnesis/life-circumstances/ai-sections/route");
const switched = await data<{ aiSections: Record<string, boolean> }>(
await (PATCH as unknown as Handler)(
request("PATCH", "/api/anamnesis/life-circumstances/ai-sections", {
sections: { HABITS: true, SMOKING_STATUS: false },
}),
),
);
expect(switched.aiSections).toMatchObject({
HABITS: true,
SMOKING_STATUS: false,
ACTIVITY: false,
});
const life = await readLife();
expect(life.sections.find((s) => s.key === "habits")?.aiIncluded).toBe(
true,
);
expect(life.sections.find((s) => s.key === "tobacco")?.aiIncluded).toBe(
false,
);
const aboutMe = await import("@/app/api/coach/about-me/route");
const read = await data<{ aiIncludedSections: string[] }>(
await (aboutMe.GET as unknown as () => Promise<Response>)(),
);
expect(read.aiIncludedSections).not.toContain("HABITS");
await data(
await (aboutMe.PUT as unknown as Handler)(
request("PUT", "/api/coach/about-me", {
aiIncludedSections: ["ABOUT_ME"],
}),
),
);
const stored = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: user.id },
});
expect([...stored.aiIncludedSections].sort()).toEqual([
"ABOUT_ME",
"HABITS",
]);
});
it("answers a stale token with about_me_conflict", async () => {
const user = await makeUser();
await signIn(user.id);
await getPrismaClient().userHealthProfile.create({
data: { userId: user.id },
});
const { PATCH } =
await import("@/app/api/anamnesis/life-circumstances/ai-sections/route");
const response = await (PATCH as unknown as Handler)(
request("PATCH", "/api/anamnesis/life-circumstances/ai-sections", {
sections: { WORK: true },
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
}),
);
expect(response.status).toBe(409);
expect(
((await response.json()) as { error: { errorCode?: string } }).error,
).toBeTruthy();
});
it("stores a confirmation as a stamp, never as a revision", async () => {
const user = await makeUser();
await signIn(user.id);
const { POST } =
await import("@/app/api/anamnesis/life-circumstances/confirm/route");
const confirmed = await data<{ section: string; lastConfirmedAt: string }>(
await (POST as unknown as Handler)(
request("POST", "/api/anamnesis/life-circumstances/confirm", {
section: "work",
}),
),
);
expect(confirmed.section).toBe("work");
const life = await readLife();
expect(life.sections.find((s) => s.key === "work")?.lastConfirmedAt).toBe(
confirmed.lastConfirmedAt,
);
expect(
await getPrismaClient().healthProfileFactRevision.count({
where: { userId: user.id },
}),
).toBe(0);
});
});
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
});
});
@@ -1169,6 +1169,59 @@ manageContract("POST /api/biomarkers/[id]/merge", {
})) === 1,
});
manageContract("POST /api/anamnesis/audit-c", {
contract: admittedContract("/api/anamnesis/audit-c", "POST"),
prepare: async (ownerId) => ({ ownerId }),
act: async () => {
const { POST } = await import("@/app/api/anamnesis/audit-c/route");
return call(POST as Handler, "POST", "/api/anamnesis/audit-c", {
items: [1, 1, 0],
source: "WEB",
});
},
ok: 201,
auditAction: "anamnesis.audit_c.create",
applied: async ({ ownerId }) =>
(await getPrismaClient().mentalHealthAssessment.count({
where: { userId: ownerId, instrument: "AUDIT_C", deletedAt: null },
})) === 1,
});
manageContract("DELETE /api/anamnesis/audit-c/[id]", {
contract: admittedContract("/api/anamnesis/audit-c/[id]", "DELETE"),
prepare: async (ownerId) =>
getPrismaClient().mentalHealthAssessment.create({
data: {
userId: ownerId,
instrument: "AUDIT_C",
locale: "en",
responsesEncrypted: Buffer.from("sealed"),
totalScore: 2,
severityBand: "unbanded",
takenAt: new Date("2026-09-01T08:00:00.000Z"),
},
select: { id: true },
}),
act: async (row) => {
const { DELETE } = await import("@/app/api/anamnesis/audit-c/[id]/route");
return call(
DELETE as Handler,
"DELETE",
`/api/anamnesis/audit-c/${row.id}`,
undefined,
{ id: row.id },
);
},
ok: 200,
auditAction: "anamnesis.audit_c.delete",
applied: async (row) =>
(
await getPrismaClient().mentalHealthAssessment.findUnique({
where: { id: row.id },
})
)?.deletedAt != null,
});
manageContract("POST /api/allergies", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) => entry.route === "/api/allergies" && entry.action === "POST",
@@ -2953,13 +3006,13 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(90);
expect(STRICT_DRIVER_KEYS.size).toBe(92);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
it("executes every registered driver through its owned effect and actor audit", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(90);
expect(REAL_EFFECT_KEYS.size).toBe(92);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});
});