diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a3e9d839e..d9447ab1d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23324,13 +23324,40 @@ paths: "403": *a11 "422": *a4 "429": *a5 - "501": &a67 - description: "Not implemented yet: the contract is published ahead of the implementation on a release branch. - `meta.errorCode: not_implemented`." + /api/day/notable: + get: + tags: + - Records + summary: Since the last visit + description: "What stood out and what changed in a window, for the preparation of the next visit: deterministic + observations (a value highest or lowest for at least three months, a first reading, a regular reading that + stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, + illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last + completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and + the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's + grant does not cover are left out." + parameters: + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The window, its observations and its changes. content: application/json: schema: - $ref: "#/components/schemas/ErrorEnvelope" + $ref: "#/components/schemas/DayNotableWindowEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/day/{date}: get: tags: @@ -23360,7 +23387,6 @@ paths: "403": *a11 "422": *a4 "429": *a5 - "501": *a67 /api/timeline: get: tags: @@ -23403,7 +23429,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a68 + "403": &a67 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -23424,7 +23450,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a67 /api/timeline/readiness: get: tags: @@ -23441,10 +23466,9 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a68 + "403": *a67 "422": *a4 "429": *a5 - "501": *a67 /api/life-events: get: tags: @@ -23463,7 +23487,6 @@ paths: "403": *a11 "422": *a4 "429": *a5 - "501": *a67 post: tags: - Records @@ -23471,6 +23494,8 @@ paths: description: "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest." + parameters: + - *a6 requestBody: required: true content: @@ -23485,9 +23510,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 + "403": *a11 + "409": *a7 "422": *a4 "429": *a12 - "501": *a67 /api/life-events/{id}: patch: tags: @@ -23515,7 +23541,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "404": &a69 + "403": *a11 + "404": &a68 description: No such life event on this record. content: application/json: @@ -23523,7 +23550,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a67 delete: tags: - Records @@ -23543,10 +23569,10 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "404": *a69 + "403": *a11 + "404": *a68 "422": *a4 "429": *a5 - "501": *a67 /api/bugreport: post: security: [] @@ -34150,21 +34176,21 @@ components: 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; 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, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; 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, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -34185,29 +34211,30 @@ components: labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, - labs.update.sourceReferenceRangeInvalid; managed_profile — managed_profile.guardian.duplicate, - managed_profile.guardian.managed_invitee, managed_profile.guardian.required, - managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian, - managed_profile.not_found; measurement — measurement.batch.invalid, - measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, - measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, - measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; - medication — medication.intake.import.invalid_format, medication.intake.notTracked; medications — - medications.category.invalid, medications.category.limitReached, medications.category.unknown, - medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, - medications.course.notFound, medications.course.oneShot, medications.course.overlap, - medications.course.windowRequired, medications.intake.bulk.apple_health_not_mirrored, - medications.intake.bulk.invalid, medications.intake.bulk.too_large, - medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, - medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start, - medications.mirror.limit_exceeded; mentalHealth — mentalHealth.rateLimited; module — module.disabled; - modules — modules.body.invalid_json, modules.invalid; mood — mood.bulk-delete.invalid, - mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, mood.create.invalid, - mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, - mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — - notification-prefs.body.invalid_json; nutrient — nutrient.batch.invalid, nutrient.batch.too_large, - nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; - onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, + labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; + managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.handover.no_pending, + managed_profile.handover.unknown_guardian, managed_profile.not_found; measurement — + measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large, + measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, + measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, + measurement.update.server_owned_source; medication — medication.intake.import.invalid_format, + medication.intake.notTracked; medications — medications.category.invalid, + medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, + medications.course.invalid, medications.course.invalidRange, medications.course.notFound, + medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, + medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, + medications.intake.bulk.too_large, medications.intake.force_slot.invalid, + medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, + medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — + mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, + modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, + mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, + mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, + mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; + nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, + nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — + onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, @@ -34228,20 +34255,20 @@ components: sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; - tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, - tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, - vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, - workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, - ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) + — 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, 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, 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`, @@ -54711,6 +54738,7 @@ components: - get_correlations - get_metric_table - get_environment + - get_day - show_result - snapshot labelKey: @@ -54766,6 +54794,9 @@ components: description: The table this step produced (`r1`..), when it produced one. type: string pattern: ^r[1-8]$ + day: + description: The calendar date a `get_day` step read (`YYYY-MM-DD`), so a view can offer the days an answer looked at. + type: string required: - id - tool @@ -54824,9 +54855,10 @@ components: - cycle - correlations - environment + - day - snapshot description: "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a - whole (labs, illness, cycle, correlations, the environment, the full snapshot)." + whole (labs, illness, cycle, correlations, the environment, one day of the record, the full snapshot)." CoachMethod: type: object properties: @@ -54911,6 +54943,7 @@ components: - get_correlations - get_metric_table - get_environment + - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -55650,6 +55683,7 @@ components: - get_correlations - get_metric_table - get_environment + - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -81641,6 +81675,167 @@ components: - days - notable additionalProperties: false + DayNotableWindowEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayNotableWindow" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayNotableWindow: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + anchor: + type: string + enum: + - lastVisit + - requested + - fallback + description: "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record + holds no visit the caller may see." + observations: + type: array + items: + $ref: "#/components/schemas/DatedDayNotable" + changes: + type: array + items: + $ref: "#/components/schemas/DayChange" + description: "Context changes in the window, oldest first: dose changes, medication and course starts and ends, pauses, + illness onsets and recoveries, vaccinations, procedures and lab days." + required: + - from + - to + - anchor + - observations + - changes + additionalProperties: false + DatedDayNotable: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + type: + anyOf: + - type: string + - type: "null" + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers + in the unit the entry names. + required: + - date + - kind + - type + - params + additionalProperties: false + DayChange: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - intake + - doseChange + - medicationStart + - medicationEnd + - pauseStart + - pauseEnd + - courseStart + - courseEnd + - illnessOnset + - illnessResolved + - illnessDayLog + - symptom + - allergyOnset + - labResult + - visit + - procedure + - vaccination + - checkup + - document + - mood + - assessment + - workout + - cycleDayLog + - lifeEvent + section: + type: string + enum: + - values + - sleep + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifestyle + - lifeEvents + id: + type: string + title: + type: string + description: The record's own name for it, decrypted for the caller. + count: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: Entries folded into this one (lab results of one day). + href: + anyOf: + - type: string + - type: "null" + required: + - date + - kind + - section + - id + - title + - count + - href + additionalProperties: false DayEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5e38ba61e..4cb661346 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10066,7 +10066,8 @@ "cycle": "Zyklus", "correlations": "Zusammenhänge zwischen Messgrößen", "snapshot": "Datenübersicht", - "environment": "Wetter und Luft" + "environment": "Wetter und Luft", + "day": "Ein Tag" }, "window": { "last7days": "letzte 7 Tage", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} hat ein Symptom entfernt", "customVaccineCreate": "{name} hat einen eigenen Impfstoff angelegt", "customVaccineUpdate": "{name} hat einen eigenen Impfstoff bearbeitet", - "customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt" + "customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt", + "lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt", + "lifeEventUpdate": "{name} hat ein Lebensereignis geändert", + "lifeEventDelete": "{name} hat ein Lebensereignis entfernt" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Werte", "mood": "Stimmung", "environment": "Umwelt" + }, + "detail": { + "weeks": "Werte in {weeks} der letzten {of} Wochen", + "startMissing": "{missing} von {total} ohne Startdatum", + "plannedOnly": "{planned} geplant, noch keiner erledigt" + }, + "gap": { + "connectSources": "Quelle verbinden", + "logMood": "Stimmung erfassen", + "logCycle": "Zyklustag erfassen", + "setHome": "Wohnort festlegen", + "addEpisode": "Erkrankung anlegen", + "addMedication": "Medikament anlegen", + "medicationStartMissing": "Startdatum ergänzen", + "addVaccination": "Impfungen erfassen", + "addVisit": "Besuch nachtragen", + "addLabs": "Laborbefund hinzufügen", + "undatedDocuments": "Dokumente datieren", + "addDocument": "Dokument hinzufügen", + "addLifeEvent": "Lebensereignis hinzufügen" } } }, diff --git a/messages/en.json b/messages/en.json index d5c9e4027..8caeba290 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10066,7 +10066,8 @@ "cycle": "Cycle", "correlations": "Connections between metrics", "snapshot": "Record summary", - "environment": "Weather and air" + "environment": "Weather and air", + "day": "One day" }, "window": { "last7days": "last 7 days", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} removed a symptom", "customVaccineCreate": "{name} added an own vaccine", "customVaccineUpdate": "{name} edited an own vaccine", - "customVaccineDelete": "{name} removed an own vaccine" + "customVaccineDelete": "{name} removed an own vaccine", + "lifeEventCreate": "{name} added a life event", + "lifeEventUpdate": "{name} changed a life event", + "lifeEventDelete": "{name} removed a life event" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Values", "mood": "Mood", "environment": "Environment" + }, + "detail": { + "weeks": "Readings in {weeks} of the last {of} weeks", + "startMissing": "{missing} of {total} without a start date", + "plannedOnly": "{planned} planned, none completed yet" + }, + "gap": { + "connectSources": "Connect a source", + "logMood": "Log a mood", + "logCycle": "Log a cycle day", + "setHome": "Set your home location", + "addEpisode": "Add an illness", + "addMedication": "Add a medication", + "medicationStartMissing": "Add the start date", + "addVaccination": "Add your vaccinations", + "addVisit": "Add a past visit", + "addLabs": "Add a lab report", + "undatedDocuments": "Date your documents", + "addDocument": "Add a document", + "addLifeEvent": "Add a life event" } } }, diff --git a/messages/es.json b/messages/es.json index 95594a427..dfc5d0c56 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10066,7 +10066,8 @@ "cycle": "Ciclo", "correlations": "Relaciones entre métricas", "snapshot": "Resumen de datos", - "environment": "Tiempo y aire" + "environment": "Tiempo y aire", + "day": "Un día" }, "window": { "last7days": "últimos 7 días", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} quitó un síntoma", "customVaccineCreate": "{name} añadió una vacuna propia", "customVaccineUpdate": "{name} editó una vacuna propia", - "customVaccineDelete": "{name} quitó una vacuna propia" + "customVaccineDelete": "{name} quitó una vacuna propia", + "lifeEventCreate": "{name} añadió un acontecimiento vital", + "lifeEventUpdate": "{name} cambió un acontecimiento vital", + "lifeEventDelete": "{name} eliminó un acontecimiento vital" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Valores", "mood": "Estado de ánimo", "environment": "Entorno" + }, + "detail": { + "weeks": "Valores en {weeks} de las últimas {of} semanas", + "startMissing": "{missing} de {total} sin fecha de inicio", + "plannedOnly": "{planned} planificadas, ninguna realizada" + }, + "gap": { + "connectSources": "Conectar una fuente", + "logMood": "Registrar el estado de ánimo", + "logCycle": "Registrar un día del ciclo", + "setHome": "Indicar tu domicilio", + "addEpisode": "Añadir una enfermedad", + "addMedication": "Añadir un medicamento", + "medicationStartMissing": "Añadir la fecha de inicio", + "addVaccination": "Añadir tus vacunas", + "addVisit": "Añadir una visita pasada", + "addLabs": "Añadir un informe de laboratorio", + "undatedDocuments": "Poner fecha a tus documentos", + "addDocument": "Añadir un documento", + "addLifeEvent": "Añadir un acontecimiento vital" } } }, diff --git a/messages/fr.json b/messages/fr.json index 9a25437e0..89558cf53 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10066,7 +10066,8 @@ "cycle": "Cycle", "correlations": "Liens entre indicateurs", "snapshot": "Résumé des données", - "environment": "Météo et air" + "environment": "Météo et air", + "day": "Une journée" }, "window": { "last7days": "7 derniers jours", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} a retiré un symptôme", "customVaccineCreate": "{name} a ajouté un vaccin personnel", "customVaccineUpdate": "{name} a modifié un vaccin personnel", - "customVaccineDelete": "{name} a retiré un vaccin personnel" + "customVaccineDelete": "{name} a retiré un vaccin personnel", + "lifeEventCreate": "{name} a ajouté un événement de vie", + "lifeEventUpdate": "{name} a modifié un événement de vie", + "lifeEventDelete": "{name} a supprimé un événement de vie" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Valeurs", "mood": "Humeur", "environment": "Environnement" + }, + "detail": { + "weeks": "Valeurs sur {weeks} des {of} dernières semaines", + "startMissing": "{missing} sur {total} sans date de début", + "plannedOnly": "{planned} prévus, aucun effectué" + }, + "gap": { + "connectSources": "Connecter une source", + "logMood": "Noter ton humeur", + "logCycle": "Noter un jour du cycle", + "setHome": "Indiquer ton domicile", + "addEpisode": "Ajouter une maladie", + "addMedication": "Ajouter un médicament", + "medicationStartMissing": "Ajouter la date de début", + "addVaccination": "Ajouter tes vaccins", + "addVisit": "Ajouter une consultation passée", + "addLabs": "Ajouter un bilan de laboratoire", + "undatedDocuments": "Dater tes documents", + "addDocument": "Ajouter un document", + "addLifeEvent": "Ajouter un événement de vie" } } }, diff --git a/messages/it.json b/messages/it.json index d8e68c0c9..ca47c9257 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10066,7 +10066,8 @@ "cycle": "Ciclo", "correlations": "Relazioni tra parametri", "snapshot": "Riepilogo dei dati", - "environment": "Meteo e aria" + "environment": "Meteo e aria", + "day": "Una giornata" }, "window": { "last7days": "ultimi 7 giorni", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} ha rimosso un sintomo", "customVaccineCreate": "{name} ha aggiunto un vaccino personale", "customVaccineUpdate": "{name} ha modificato un vaccino personale", - "customVaccineDelete": "{name} ha rimosso un vaccino personale" + "customVaccineDelete": "{name} ha rimosso un vaccino personale", + "lifeEventCreate": "{name} ha aggiunto un evento della vita", + "lifeEventUpdate": "{name} ha modificato un evento della vita", + "lifeEventDelete": "{name} ha rimosso un evento della vita" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Valori", "mood": "Umore", "environment": "Ambiente" + }, + "detail": { + "weeks": "Valori in {weeks} delle ultime {of} settimane", + "startMissing": "{missing} su {total} senza data di inizio", + "plannedOnly": "{planned} programmate, nessuna svolta" + }, + "gap": { + "connectSources": "Collega una fonte", + "logMood": "Registra l’umore", + "logCycle": "Registra un giorno del ciclo", + "setHome": "Imposta la tua residenza", + "addEpisode": "Aggiungi una malattia", + "addMedication": "Aggiungi un farmaco", + "medicationStartMissing": "Aggiungi la data di inizio", + "addVaccination": "Aggiungi le vaccinazioni", + "addVisit": "Aggiungi una visita passata", + "addLabs": "Aggiungi un referto di laboratorio", + "undatedDocuments": "Data i tuoi documenti", + "addDocument": "Aggiungi un documento", + "addLifeEvent": "Aggiungi un evento della vita" } } }, diff --git a/messages/ko.json b/messages/ko.json index 9b35bfea8..e79f48350 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10066,7 +10066,8 @@ "cycle": "생리 주기", "correlations": "지표 간 연관성", "snapshot": "기록 요약", - "environment": "날씨와 대기" + "environment": "날씨와 대기", + "day": "하루" }, "window": { "last7days": "최근 7일", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다", "customVaccineCreate": "{name} 님이 백신을 직접 추가했어요", "customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요", - "customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요" + "customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요", + "lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요", + "lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요", + "lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "수치", "mood": "기분", "environment": "환경" + }, + "detail": { + "weeks": "최근 {of}주 중 {weeks}주에 기록이 있어요", + "startMissing": "{total}개 중 {missing}개에 시작일이 없어요", + "plannedOnly": "{planned}건 예정, 완료된 진료는 아직 없어요" + }, + "gap": { + "connectSources": "데이터 소스 연결", + "logMood": "기분 기록", + "logCycle": "주기 기록", + "setHome": "거주지 설정", + "addEpisode": "질환 추가", + "addMedication": "약 추가", + "medicationStartMissing": "시작일 추가", + "addVaccination": "예방접종 기록", + "addVisit": "지난 진료 추가", + "addLabs": "검사 결과 추가", + "undatedDocuments": "문서에 날짜 추가", + "addDocument": "문서 추가", + "addLifeEvent": "삶의 사건 추가" } } }, diff --git a/messages/pl.json b/messages/pl.json index fc3569b56..a0263551f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10066,7 +10066,8 @@ "cycle": "Cykl", "correlations": "Związki między wskaźnikami", "snapshot": "Podsumowanie danych", - "environment": "Pogoda i powietrze" + "environment": "Pogoda i powietrze", + "day": "Jeden dzień" }, "window": { "last7days": "ostatnie 7 dni", @@ -11830,7 +11831,10 @@ "symptomDefinitionDelete": "{name} usunął(-ęła) objaw", "customVaccineCreate": "{name} dodał własną szczepionkę", "customVaccineUpdate": "{name} zmienił własną szczepionkę", - "customVaccineDelete": "{name} usunął własną szczepionkę" + "customVaccineDelete": "{name} usunął własną szczepionkę", + "lifeEventCreate": "{name} dodał wydarzenie życiowe", + "lifeEventUpdate": "{name} zmienił wydarzenie życiowe", + "lifeEventDelete": "{name} usunął wydarzenie życiowe" }, "section": { "measurements": { @@ -12255,6 +12259,26 @@ "values": "Wartości", "mood": "Nastrój", "environment": "Otoczenie" + }, + "detail": { + "weeks": "Pomiary w {weeks} z ostatnich {of} tygodni", + "startMissing": "{missing} z {total} bez daty rozpoczęcia", + "plannedOnly": "{planned} zaplanowane, żadna nie odbyła się" + }, + "gap": { + "connectSources": "Połącz źródło", + "logMood": "Zapisz nastrój", + "logCycle": "Zapisz dzień cyklu", + "setHome": "Ustaw miejsce zamieszkania", + "addEpisode": "Dodaj chorobę", + "addMedication": "Dodaj lek", + "medicationStartMissing": "Dodaj datę rozpoczęcia", + "addVaccination": "Dodaj szczepienia", + "addVisit": "Dodaj minioną wizytę", + "addLabs": "Dodaj wynik badań", + "undatedDocuments": "Dodaj daty dokumentów", + "addDocument": "Dodaj dokument", + "addLifeEvent": "Dodaj wydarzenie życiowe" } } }, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9277e1b77..1324fca12 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1015,22 +1015,31 @@ const DELEGABLE_ROUTES: Record = { domain: "illness", why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.", }, - // v1.42 (#613) — the day, the day index, the timeline and its readiness - // are reads across every section, so they declare the whole record, the - // posture of the dashboard summary: a full-record grant reads them, a - // scoped grant never does. Narrowing them to the grant's own sections is a - // change of admission, not of this line, and comes back here for review. + // v1.42 (#613) — the day, its index and the visit preparation are admitted + // on the readings and narrowed section by section after that: every other + // section is read only when the grant also covers that section's own + // domain (`actingDomainVisibility`), and a section it does not cover is + // named `not_shared` and never read. The section-to-domain map + // (`src/lib/day/sections.ts`) is the one the routes serving those rows + // declare here, so the day never shows more of a section than its own list + // would. The timeline and its readiness lay every section side by side and + // stay whole-record reads; their lanes are narrowed the same way all the + // same. "app/api/day/[date]/route.ts": { - domain: "record", - why: "One local day across the record: what ran through it, the readings in its window, what happened on it. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it. Life events ride along under `profile`, which a full-record grant covers.", + domain: "measurements", + why: "One local day of the record: the readings in its window, and the other sections only where the grant covers each one's own domain, the rest named not_shared and never read. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it.", }, "app/api/day/index/route.ts": { - domain: "record", - why: "Which days of a bounded window hold anything, by section key. Dates and section names only, no values and no free text; every read scoped to the resolved user.", + domain: "measurements", + why: "Which days of a bounded window hold anything, by section key, narrowed per section like the day. Dates and section names only, no values and no free text; every read scoped to the resolved user.", + }, + "app/api/day/notable/route.ts": { + domain: "measurements", + why: "The preparation of the next visit: notable days of the readings and the context changes of the sections the grant covers, as keys and the record's own names. Read-only, scoped to the resolved user, narrowed per section like the day.", }, "app/api/timeline/route.ts": { domain: "record", - why: "The record over the years: spans and points from every section plus monthly means. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.", + why: "The record over the years: spans and points from every section plus value series. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.", }, "app/api/timeline/readiness/route.ts": { domain: "record", @@ -1038,7 +1047,11 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/life-events/route.ts": { domain: "profile", - why: "The record's life events, a section of health background beside allergies and visits; the consent copy for `profile` names them. The GET reads rows scoped `userId: user.id` and decrypts title and note for the resolved session. The create beside it keeps `requireAuth()` until it files an audit row and joins the write literal.", + why: "The record's life events, a section of health background beside allergies and visits; the consent copy for `profile` names them. The GET reads rows scoped `userId: user.id` and decrypts title and note for the resolved session; the create is a MANAGE verb (see the manage literal).", + }, + "app/api/life-events/[id]/route.ts": { + domain: "profile", + why: "One life event of the record, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; an event another record holds is a 404 like any foreign id.", }, "app/api/workouts/route.ts": { domain: "measurements", @@ -1643,6 +1656,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C4"], why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.", }, + "app/api/life-events/route.ts": { + domain: "profile", + conditions: [], + why: "Recording a life event. The person's own anchor, so a guardian's act on a managed profile rather than a helper's; additive, rate-limited on the actor, audited with the category and precision and never the title.", + }, + "app/api/life-events/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing a life event. The removal soft-deletes and the audit row names the event's category and date; C4 on the edit: the dates, category and precision are filed before and after, and a changed title or note is named, never quoted.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -2056,12 +2079,15 @@ const ACTOR_ROUTES: Record = { * edit/delete on the manage literal. 242 -> 246. * * v1.42 -- the day view and the timeline add five reads on the record list: - * the day, the day index, the timeline, its readiness inventory (all four - * across the whole record) and the life-event list in `profile`. The life-event - * create, edit and delete stay owner-only until they carry their audit rows. - * 246 -> 251. + * the day, the day index, the timeline, its readiness inventory and the + * life-event list in `profile`. 246 -> 251. + * + * v1.42 -- the visit preparation joins the record list beside the day, and + * the life-event writes join it with their audit rows: the edit/delete module + * on the record list, the create and the edit/delete on the manage literal. + * 251 -> 255. */ -const FROZEN_ENTRY_COUNT = 251; +const FROZEN_ENTRY_COUNT = 255; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2822,7 +2848,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(87); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 82ba66592..dd85c335d 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/day/[date]/route.ts b/src/app/api/day/[date]/route.ts index f1ef52ff0..5ca359c8a 100644 --- a/src/app/api/day/[date]/route.ts +++ b/src/app/api/day/[date]/route.ts @@ -2,21 +2,58 @@ * `GET /api/day/{date}` — one local day across the record (v1.42, #613): what * ran through it, the readings in its window, what happened on it, and the * few deterministic observations worth a second look. The shape is - * `DayResponse` in `src/lib/day/contract.ts`. + * `DayResponse` in `src/lib/day/contract.ts`; `loadDay` builds it. * * Core, not a module: the day opens for every account. Sections of a - * switched-off module are left out; a section the caller's grant does not - * cover is named in `sections` and not read. + * switched-off module are left out. * - * Contract stub: answers 501 behind its admission. It declares the whole - * record for now, the posture every cross-section read in the tree has; a - * scoped grant is refused until the loader narrows each section to the - * grant's domains and the admission is reviewed for that. + * Admission: a read of the record's readings (`measurements`), narrowed + * section by section after that. The day is built around the readings, so a + * grant has to open them before it opens the day; every other section is then + * read only when the grant also covers that section's own domain + * (`actingDomainVisibility`, the same predicate the body-site and visit routes + * narrow with), and a section it does not cover is named in `sections` with + * `not_shared` and never read. No section can show a delegate more than the + * section's own route would: the domain each one maps to is the one those + * routes declare (`src/lib/day/sections.ts`). */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { loadDay } from "@/lib/day/load-day"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { dayPathSchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; -export const GET = apiHandler(async () => { - await requireRecordAuth("read", "record"); - return notImplemented(); +type RouteParams = { params: Promise<{ date: string }> }; + +export const GET = apiHandler(async (_request: Request, ctx: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const parsed = dayPathSchema.safeParse(await ctx.params); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const access = await resolveDayAccess({ + recordId: user.id, + domainVisible: await actingDomainVisibility(prisma, grantId), + owner: grantId === null, + }); + const day = await loadDay({ + recordId: user.id, + day: parsed.data.date, + access, + }); + annotate({ + action: { name: "day.read" }, + meta: { + values: day.counts.values, + entries: day.counts.entries, + running: day.running.length, + notable: day.notable.length, + not_shared: Object.keys(day.sections).length, + delegated: grantId !== null, + }, + }); + return apiSuccess(day); }); diff --git a/src/app/api/day/index/route.ts b/src/app/api/day/index/route.ts index c922b291d..08269d4fe 100644 --- a/src/app/api/day/index/route.ts +++ b/src/app/api/day/index/route.ts @@ -5,12 +5,53 @@ * `DAY_INDEX_MAX_SPAN_DAYS` days per call. The shape is `DayIndexResponse` in * `src/lib/day/contract.ts`. * - * Contract stub: answers 501 behind the same admission as the day itself. + * Same admission and the same section narrowing as the day itself: a grant + * that does not cover a section never sees that section's days. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { loadDayIndex } from "@/lib/day/day-index"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { dayIndexQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; -export const GET = apiHandler(async () => { - await requireRecordAuth("read", "record"); - return notImplemented(); +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const params = new URL(request.url).searchParams; + const parsed = dayIndexQuerySchema.safeParse({ + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const [access, tz] = await Promise.all([ + actingDomainVisibility(prisma, grantId).then((domainVisible) => + resolveDayAccess({ + recordId: user.id, + domainVisible, + owner: grantId === null, + }), + ), + resolveUserTimezone(user.id), + ]); + const index = await loadDayIndex({ + recordId: user.id, + from: parsed.data.from, + to: parsed.data.to, + access, + tz, + }); + annotate({ + action: { name: "day.index.read" }, + meta: { + days: Object.keys(index.days).length, + notable: index.notable.length, + delegated: grantId !== null, + }, + }); + return apiSuccess(index); }); diff --git a/src/app/api/day/notable/route.ts b/src/app/api/day/notable/route.ts new file mode 100644 index 000000000..74cc8ec9f --- /dev/null +++ b/src/app/api/day/notable/route.ts @@ -0,0 +1,60 @@ +/** + * `GET /api/day/notable?from=&to=` — what changed and what stood out in a + * window, for the preparation of the next visit (v1.42, #613). Without + * `from` the window starts at the last completed visit the caller may see; + * without `to` it ends today. The shape is `DayNotableWindowResponse` in + * `src/lib/day/contract.ts`. + * + * Observations and changes only, as keys and the record's own names: no + * value judgement, no cause. Same admission and section narrowing as the day. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { loadSinceLastVisit } from "@/lib/day/visit-preparation"; +import { dayNotableQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const params = new URL(request.url).searchParams; + const parsed = dayNotableQuerySchema.safeParse({ + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const [access, tz] = await Promise.all([ + actingDomainVisibility(prisma, grantId).then((domainVisible) => + resolveDayAccess({ + recordId: user.id, + domainVisible, + owner: grantId === null, + }), + ), + resolveUserTimezone(user.id), + ]); + const window = await loadSinceLastVisit({ + recordId: user.id, + access, + tz, + today: userDayKey(new Date(), tz), + from: parsed.data.from, + to: parsed.data.to, + }); + annotate({ + action: { name: "day.notable.read" }, + meta: { + anchor: window.anchor, + observations: window.observations.length, + changes: window.changes.length, + delegated: grantId !== null, + }, + }); + return apiSuccess(window); +}); diff --git a/src/app/api/life-events/[id]/route.ts b/src/app/api/life-events/[id]/route.ts index ce076032e..a9eee8948 100644 --- a/src/app/api/life-events/[id]/route.ts +++ b/src/app/api/life-events/[id]/route.ts @@ -1,21 +1,142 @@ /** * `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes - * it (v1.42, #613). Record-scoped like the list beside it. + * it (v1.42, #613). Record-scoped like the list beside it, and MANAGE like + * the create: correcting or removing somebody's life event is a guardian's + * act. A foreign, unknown or already deleted id is the same 404. * - * Contract stub: owner-only until implemented, then answers 501. The - * finished routes resolve the record through `requireRecordAuth` and join - * the sharing guard's delegable lists with their audit rows; a stub cannot - * carry those, so it does not claim the admission. + * The edit files the replaced dates, category and precision (C4) and names a + * changed title or note without quoting either. The delete tombstones the + * row, so the audit row names it and the restore of a backup keeps it. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const PATCH = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + lifeEventIdPathSchema, + lifeEventUpdateSchema, +} from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { + findOwnLifeEvent, + mergeLifeEventEdit, + toLifeEventDTO, +} from "@/lib/life-events/service"; +import { annotate } from "@/lib/logging/context"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; -export const DELETE = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound() { + return apiError("Life event not found", 404, { + errorCode: "life_event.not_found", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + const path = lifeEventIdPathSchema.safeParse(await params); + if (!path.success) return notFound(); + const existing = await findOwnLifeEvent(user.id, path.data.id); + if (!existing) return notFound(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = lifeEventUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "life_event.invalid", + }); + } + const merged = mergeLifeEventEdit(existing, parsed.data); + if (!merged.ok) { + return returnAllZodIssues(merged.error, 422, { + errorCode: "life_event.invalid", + }); + } + + const updated = await prisma.lifeEvent.update({ + where: { id: existing.id }, + data: merged.data, + }); + + await auditLog("life_event.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + lifeEventId: existing.id, + ...overwriteDetails({ + before: { + category: existing.category, + startDate: existing.startDate, + endDate: existing.endDate, + precision: existing.precision, + }, + after: { + category: updated.category, + startDate: updated.startDate, + endDate: updated.endDate, + precision: updated.precision, + }, + redacted: [ + ...(parsed.data.title !== undefined ? ["title"] : []), + ...(parsed.data.note !== undefined ? ["note"] : []), + ], + }), + }, + }); + annotate({ + action: { + name: "life_event.update", + entity_type: "life_event", + entity_id: existing.id, + }, + }); + return apiSuccess(toLifeEventDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + const path = lifeEventIdPathSchema.safeParse(await params); + if (!path.success) return notFound(); + const existing = await findOwnLifeEvent(user.id, path.data.id); + if (!existing) return notFound(); + + const deleted = await prisma.lifeEvent.update({ + where: { id: existing.id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("life_event.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + lifeEventId: existing.id, + category: existing.category, + startDate: existing.startDate, + precision: existing.precision, + }, + }); + annotate({ + action: { + name: "life_event.delete", + entity_type: "life_event", + entity_id: existing.id, + }, + }); + return apiSuccess(toLifeEventDTO(deleted)); + }, +); diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts index 3478c7af4..543a44d5f 100644 --- a/src/app/api/life-events/route.ts +++ b/src/app/api/life-events/route.ts @@ -8,19 +8,86 @@ * module is on, the data-layer posture of the other record tables, so a * restore keeps working and re-enabling finds the events intact. * - * Contract stub: answers 501. The read declares its final admission; the - * create stays owner-only until it files its rows through the audit trail - * and joins the delegable write list. + * The create is a MANAGE verb: a life event is the person's own anchor, and + * recording one for somebody else is a guardian's act on a managed profile, + * not a helper's. Additive, rate-limited on the actor, audited with the + * category and the precision and never the title. */ -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { lifeEventCreateSchema } from "@/lib/day/wire-schemas"; +import { withIdempotency } from "@/lib/idempotency"; +import { + createLifeEvent, + listLifeEvents, + toLifeEventDTO, +} from "@/lib/life-events/service"; +import { annotate } from "@/lib/logging/context"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; export const GET = apiHandler(async () => { - await requireRecordAuth("read", "profile"); - return notImplemented(); + const { user } = await requireRecordAuth("read", "profile"); + const events = await listLifeEvents(user.id); + annotate({ + action: { name: "life_event.list", entity_type: "life_event" }, + meta: { count: events.length }, + }); + return apiSuccess({ events }); }); -export const POST = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent)); + +async function postLifeEvent(request: NextRequest): Promise { + const { user, actor } = await requireRecordAuth("manage", "profile"); + + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = lifeEventCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "life_event.invalid", + }); + } + + const created = await createLifeEvent(user.id, parsed.data); + + await auditLog("life_event.create", { + userId: user.id, + ipAddress: getClientIp(request), + // Never the title or the note: both are encrypted at rest and the audit + // table is not a second store for them. + details: { + lifeEventId: created.id, + category: created.category, + precision: created.precision, + }, + }); + annotate({ + action: { + name: "life_event.create", + entity_type: "life_event", + entity_id: created.id, + }, + meta: { category: created.category, precision: created.precision }, + }); + return apiSuccess(toLifeEventDTO(created), 201); +} diff --git a/src/app/api/timeline/readiness/route.ts b/src/app/api/timeline/readiness/route.ts index 4113a845b..0f4be4737 100644 --- a/src/app/api/timeline/readiness/route.ts +++ b/src/app/api/timeline/readiness/route.ts @@ -1,18 +1,42 @@ /** * `GET /api/timeline/readiness` — what the timeline can already show, lane by * lane, with one link per gap (v1.42, #613). Counts and keys, never a score. - * The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`. + * The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`; + * `loadTimelineReadiness` builds it. * - * Gated on the opt-in `timeline` module. Contract stub: answers 501 behind - * its admission and the gate. + * Gated on the opt-in `timeline` module, admitted like the timeline, and + * narrowed the same way: a lane the grant does not cover is not listed. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { loadTimelineReadiness } from "@/lib/timeline/readiness"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "record"); + const { user, grantId } = await requireRecordAuth("read", "record"); const gate = await requireModuleEnabled(user.id, "timeline"); if (!gate.enabled) return gate.response; - return notImplemented(); + const [modules, domainVisible, tz] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const readiness = await loadTimelineReadiness({ + recordId: user.id, + tz, + access: { modules, domainVisible, owner: grantId === null }, + }); + annotate({ + action: { name: "timeline.readiness.read" }, + meta: { + verdict: readiness.verdict, + lanes: readiness.lanes.length, + carrying: readiness.lanes.filter((l) => l.status === "carries").length, + }, + }); + return apiSuccess(readiness); }); diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index cfc16d284..6180427b2 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -1,20 +1,76 @@ /** * `GET /api/timeline?zoom=&from=&to=&values=` — the record laid out over the * years (v1.42, #613): lanes of conditions, medications, visits, documents and - * life events, the standing items without a start, and monthly value series. - * The shape is `TimelineResponse` in `src/lib/day/contract.ts`. + * life events, the standing items without a start, and value series by zoom. + * The shape is `TimelineResponse` in `src/lib/day/contract.ts`; `loadTimeline` + * builds it. * - * Gated on the opt-in `timeline` module. Contract stub: answers 501 behind - * its admission and the gate. Like the day, it declares the whole record - * until each lane is narrowed to the grant's domains. + * Gated on the opt-in `timeline` module. Admission declares the whole record: + * the timeline lays every section side by side, so a grant scoped to some of + * them does not open it. The lanes are narrowed to the grant's domains all + * the same (`actingDomainVisibility`), so the day this admission is reviewed + * and narrowed the loader already reads no lane it should not. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { timelineQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { loadTimeline, parseSeriesKeys } from "@/lib/timeline/load-timeline"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; -export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "record"); +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "record"); const gate = await requireModuleEnabled(user.id, "timeline"); if (!gate.enabled) return gate.response; - return notImplemented(); + + const params = new URL(request.url).searchParams; + const parsed = timelineQuerySchema.safeParse({ + zoom: params.get("zoom") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + values: params.get("values") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "timeline.invalid", + }); + } + if (parsed.data.from && parsed.data.to && parsed.data.from > parsed.data.to) { + return apiError("`from` must not be after `to`", 422, { + errorCode: "timeline.invalid", + }); + } + const seriesKeys = parseSeriesKeys(parsed.data.values); + if (seriesKeys === null) { + return apiError("Unknown value series", 422, { + errorCode: "timeline.invalid", + }); + } + + const [modules, domainVisible, tz] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const timeline = await loadTimeline({ + recordId: user.id, + query: parsed.data, + seriesKeys, + access: { modules, domainVisible, owner: grantId === null }, + tz, + }); + annotate({ + action: { name: "timeline.read" }, + meta: { + zoom: timeline.zoom, + lanes: timeline.lanes.length, + items: timeline.lanes.reduce((n, l) => n + l.items.length, 0), + series: timeline.series.length, + notable: timeline.notable.length, + }, + }); + return apiSuccess(timeline); }); diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index b747ce26b..0424c7276 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -113,6 +113,7 @@ const READ_TOOLS = [ "get_intraday_pulse", "get_ecg_recordings", "get_environment", + "get_day", // v1.38 — the bounded visit history. "get_visits", ].sort(); diff --git a/src/components/insights/coach-panel/turn-activity.tsx b/src/components/insights/coach-panel/turn-activity.tsx index 265b91760..081ecf30c 100644 --- a/src/components/insights/coach-panel/turn-activity.tsx +++ b/src/components/insights/coach-panel/turn-activity.tsx @@ -40,6 +40,7 @@ import { Brain, ChartLine, ChartScatter, + CalendarDays, CloudSun, ChevronDown, CircleStop, @@ -275,6 +276,8 @@ export function stepIcon( return ChartScatter; case "get_environment": return CloudSun; + case "get_day": + return CalendarDays; case "get_metric_table": return Table2; default: diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index ce740b5c4..f9293bcaf 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -246,6 +246,8 @@ const CASES: Record>> = { get_cycle: [{}], get_correlations: [{}], get_environment: [{ window: "allTime" }, {}], + // A day before the 30-day limit is refused before anything is read. + get_day: [{ date: "2000-01-03" }], get_metric_table: [ { metric: "bp", window: "allTime" }, { metric: "weight", window: "last30days", period: "yearAgo" }, diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index e228a7496..db31aad96 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -13,6 +13,8 @@ */ import { z } from "zod/v4"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; + import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types"; import { ACTIVITY_TEXT_MAX_CHARS, @@ -50,12 +52,13 @@ export const coachStepDomainSchema = z "cycle", "correlations", "environment", + "day", "snapshot", ]) .meta({ id: "CoachStepDomain", description: - "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the environment, the full snapshot).", + "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the environment, one day of the record, the full snapshot).", }); const coachResultGranularitySchema = z.enum(["day", "week", "month"]); @@ -109,6 +112,13 @@ export const coachStepSchema = z resultRef: resultRefSchema .optional() .describe("The table this step produced (`r1`..), when it produced one."), + day: z + .string() + .refine(isCalendarDateKey) + .optional() + .describe( + "The calendar date a `get_day` step read (`YYYY-MM-DD`), so a view can offer the days an answer looked at.", + ), }) .meta({ id: "CoachStep", diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts index 288e9552c..31338d23b 100644 --- a/src/lib/ai/coach/tools/availability.ts +++ b/src/lib/ai/coach/tools/availability.ts @@ -728,6 +728,8 @@ export function subjectForTool( // v1.42 — the environment read probes its own table and reports its own // reason (module_disabled / no_data / outside_window). case "get_environment": + // v1.42 — the day read answers for one date and reports its own reason. + case "get_day": return null; } } diff --git a/src/lib/ai/coach/tools/day-read.ts b/src/lib/ai/coach/tools/day-read.ts new file mode 100644 index 000000000..ac1c51e7c --- /dev/null +++ b/src/lib/ai/coach/tools/day-read.ts @@ -0,0 +1,84 @@ +/** + * `get_day` for the Coach and the MCP endpoint (v1.42, #613). + * + * The day the person sees, through the same loader, projected for a model: + * life events and notes never leave (`model-projection.ts`), and a section of + * a switched-off module is named as such rather than read. Life events are + * not even loaded on this path: the sections the projection would drop are + * taken out of the access set before the loader runs, so their rows are + * neither read nor decrypted for a model's request. + * + * Read on the person's own record only: both callers act for the owner. + */ +import { + MODEL_EXCLUDED_DAY_SECTIONS, + type DaySectionKey, +} from "@/lib/day/contract"; +import { loadDay } from "@/lib/day/load-day"; +import { projectDayForModel, type ModelDay } from "@/lib/day/model-projection"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { + reachFloor, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; +import { userDayKey } from "@/lib/tz/format"; +import { localDayWindow } from "@/lib/tz/local-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export type DayToolResult = + | { present: true; data: ModelDay } + | { + present: false; + reason: "no_data" | "outside_window" | "outside_reach"; + }; + +const EXCLUDED: ReadonlySet = new Set( + MODEL_EXCLUDED_DAY_SECTIONS, +); + +export async function readDayForTool(args: { + userId: string; + date: string; + reach: CoachHistoryReach; + now?: Date; + /** Wraps a free-text leaf (a title) for the reader; identity by default. */ + text?: (value: string) => string; +}): Promise { + const now = args.now ?? new Date(); + const tz = await resolveUserTimezone(args.userId); + if (args.date > userDayKey(now, tz)) { + return { present: false, reason: "outside_window" }; + } + // A day that ended before the lookback limit is refused before anything + // is read; a day inside it reads its comparisons no further back. + const floor = reachFloor(args.reach, now); + const { dayEnd } = localDayWindow(args.date, tz); + if (floor !== null && dayEnd <= floor) { + return { present: false, reason: "outside_reach" }; + } + + const access = await resolveDayAccess({ + recordId: args.userId, + domainVisible: () => true, + owner: true, + }); + const readable = new Set( + [...access.readable].filter((section) => !EXCLUDED.has(section)), + ); + const day = await loadDay({ + recordId: args.userId, + day: args.date, + access: { ...access, readable }, + tz, + floor, + }); + const data = projectDayForModel(day, access.moduleOff, args.text); + if ( + data.values.length === 0 && + data.events.length === 0 && + data.running.length === 0 + ) { + return { present: false, reason: "no_data" }; + } + return { present: true, data }; +} diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index d23fefd77..296e91f98 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -26,6 +26,10 @@ * 11. get_environment — the weather, air quality, pollen and UV of * the stored days, without any location * (v1.42, #615) + * 12. get_day — one local day across the record: what ran + * through it, its readings with the usual + * range, what happened on it; never the + * person's life events or notes (v1.42, #613) * * Beside the catalogue, `show_result` (v1.39.4) shows a table an earlier reply * of the same conversation already read. It reads no health data of its own, @@ -34,6 +38,8 @@ import { z } from "zod/v4"; import type { AiToolDef } from "@/lib/ai/types"; +import type { DayToolInput } from "@/lib/day/contract"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; import { coachScopeSourceSchema, coachScopeWindowSchema, @@ -52,6 +58,7 @@ export const COACH_TOOL_NAMES = [ "get_correlations", "get_metric_table", "get_environment", + "get_day", ] as const; export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; @@ -138,6 +145,18 @@ export const getEnvironmentArgsSchema = z }) .strict(); +/** + * v1.42 (#613) — the day read takes one calendar date. The name and the + * argument are the contract's (`DAY_TOOL_NAME`, `DayToolInput`). + */ +export const getDayArgsSchema = z + .object({ + date: z.string().refine(isCalendarDateKey, { + message: "Expected a YYYY-MM-DD calendar date", + }), + }) + .strict() satisfies z.ZodType; + /** * v1.39.4 — `m.r`, a table of an earlier reply as the context names * it. The shape only; whether it exists in this conversation is decided by @@ -165,6 +184,7 @@ const COACH_TOOL_ARG_SCHEMAS: Record = { get_correlations: getCorrelationsArgsSchema, get_metric_table: getMetricTableArgsSchema, get_environment: getEnvironmentArgsSchema, + get_day: getDayArgsSchema, }; /** @@ -416,6 +436,23 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ }, }, }, + { + name: "get_day", + description: + "Fetch one local calendar day of the user's record: what ran through it (medications and courses with their dose and day n, a pause, an illness with its day n, a cycle phase, an anamnesis fact such as shift work), the readings in the day's own time-zone window with the user's usual range over the 30 days before (median and spread of their own daily values, null with too little history), what happened on it (intakes, dose changes, symptoms, lab results, visits, vaccinations, completed check-ups, documents by kind, mood and screener scores, workouts), and deterministic notable observations (a value highest or lowest for at least three months, the first reading of a kind). Notes and life events are never included. `unavailable` lists the sections the user switched off (module_disabled). Describe what the day held; never claim that one thing on it caused another. Returns { present: false } with no_data (nothing recorded that day), outside_window (a future date) or outside_reach (older than the lookback limit).", + parameters: { + type: "object", + additionalProperties: false, + required: ["date"], + properties: { + date: { + type: "string", + description: + "The local calendar date, YYYY-MM-DD, in the user's own time zone.", + }, + }, + }, + }, { name: SHOW_RESULT_TOOL_NAME, description: diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 1c5ce9190..c8b336026 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -107,6 +107,7 @@ import { getCorrelationsArgsSchema, getMetricTableArgsSchema, getEnvironmentArgsSchema, + getDayArgsSchema, isCoachToolName, showResultArgsSchema, SHOW_RESULT_TOOL_NAME, @@ -118,6 +119,7 @@ import { } from "./source-keys"; import { readCoachCorrelations } from "./correlations-read"; import { readEnvironmentForTool } from "./environment-read"; +import { readDayForTool } from "./day-read"; import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; import { resolveEmptyRead, @@ -493,6 +495,8 @@ async function dispatchRead( ); case "get_environment": return getEnvironment(userId, rawArgs, fallbackWindow, reach); + case "get_day": + return getDay(userId, rawArgs, reach); } } @@ -1013,6 +1017,27 @@ async function getCorrelations( }; } +/** + * v1.42 (#613) — one local day across the record, projected for a model: + * never the person's life events or notes. A day older than the lookback + * limit is refused before anything is read. + */ +async function getDay( + userId: string, + rawArgs: unknown, + reach: CoachHistoryReach, +): Promise { + const parsed = getDayArgsSchema.safeParse(rawArgs); + if (!parsed.success) return badArgs("get_day", parsed.error); + const result = await readDayForTool({ + userId, + date: parsed.data.date, + reach, + }); + if (result.present) return { present: true, data: result.data }; + return { present: false, reason: result.reason }; +} + /** * v1.42 (#615) — the stored days' weather and air quality, without any * location. Module-gated inside the reader (off answers `module_disabled` diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 2d1f39de3..27c0b9b33 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -248,6 +248,14 @@ export async function buildCoachDataInventory( }); } + // v1.42 (#613) — one day across the record, by date. Always offered: the + // tool answers no_data itself for a day that holds nothing. + entries.push({ + tool: "get_day", + domain: "one day across the record, by date (YYYY-MM-DD)", + present: true, + }); + // Every row that came back absent gets checked against the record before the // manifest says "absent". A domain whose whole history predates the window — // an imported year of readings, a device that stopped syncing — is otherwise diff --git a/src/lib/ai/coach/turn/__tests__/steps.test.ts b/src/lib/ai/coach/turn/__tests__/steps.test.ts index d6de77677..a679912ba 100644 --- a/src/lib/ai/coach/turn/__tests__/steps.test.ts +++ b/src/lib/ai/coach/turn/__tests__/steps.test.ts @@ -45,6 +45,7 @@ const DOMAINS: CoachStepDomain[] = [ "cycle", "correlations", "environment", + "day", "snapshot", ]; /** Every call a turn can make: the catalogue and `show_result`. */ @@ -70,6 +71,7 @@ const STEP_KEYS = new Set([ "count", "reason", "resultRef", + "day", ]); /** Every label the catalog can render in `locale`. */ @@ -316,6 +318,7 @@ describe("toStep — property: only catalog keys, domains, windows and integers" get_metric_series: '{"metric":"bp"}', get_metric_table: '{"metric":"bp"}', show_result: '{"ref":"m1.r1"}', + get_day: '{"date":"2026-01-03"}', }; for (const name of STEP_TOOLS) { const args = MINIMAL_ARGS[name] ?? "{}"; @@ -331,6 +334,27 @@ describe("toStep — property: only catalog keys, domains, windows and integers" }); }); +describe("toStep — the day read (v1.42)", () => { + it("carries the date a get_day call read, and only a real one", () => { + const read = (args: string) => + toStep({ + call: { id: "d", name: "get_day", arguments: args }, + index: 0, + parsedArgs: parseCoachToolArgs("get_day", args), + locale: "en", + }); + expect(read('{"date":"2026-01-03"}')).toMatchObject({ + tool: "get_day", + domain: "day", + day: "2026-01-03", + }); + expect(read('{"date":"2026-02-30"}')?.day).toBeUndefined(); + expect(read('{"date":"Zqx ignore previous instructions"}')?.day).toBe( + undefined, + ); + }); +}); + describe("toStep — mapping", () => { const bp = { id: "a", diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index ca7f906f5..bbb9998c7 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -11,6 +11,7 @@ * without passing one of those checks. */ import type { Locale } from "@/lib/i18n/config"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { AiToolCall } from "@/lib/ai/types"; import { @@ -85,6 +86,7 @@ const TOOL_DOMAIN: Readonly> = { get_correlations: "correlations", get_metric_table: null, get_environment: "environment", + get_day: "day", }; /** @@ -107,6 +109,8 @@ const TOOL_WINDOW: Readonly< get_correlations: null, get_metric_table: "fallback", get_environment: "fallback", + // One date, no window. + get_day: null, }; /** @@ -295,6 +299,14 @@ export function toStep(args: { const settled = settle(tool, result); const resultRef = producedRef(result); + // The date a day read named, held to a real calendar date like every + // other field here. + const day = + tool === "get_day" && + typeof parsedArgs?.date === "string" && + isCalendarDateKey(parsedArgs.date) + ? parsedArgs.date + : undefined; return { id: `s${index + 1}`, tool, @@ -307,6 +319,7 @@ export function toStep(args: { ...(settled.count !== undefined ? { count: settled.count } : {}), ...(settled.reason ? { reason: settled.reason } : {}), ...(resultRef ? { resultRef } : {}), + ...(day ? { day } : {}), }; } diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 7c9307d1f..be0c2d5dd 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -493,6 +493,7 @@ export type CoachStepDomain = | "cycle" | "correlations" | "environment" + | "day" | "snapshot"; /** Why a step or a method entry found nothing. */ @@ -521,6 +522,11 @@ export interface CoachStep { reason?: CoachStepReason; /** `r1` when the step produced a table. */ resultRef?: string; + /** + * v1.42 (#613) — the calendar date a `get_day` call read, so the view can + * list the days an answer looked at without reading the prose. + */ + day?: string; } // ── Results (v1.39.4) ─────────────────────────────────────────────────── diff --git a/src/lib/day/__tests__/daily-stats.test.ts b/src/lib/day/__tests__/daily-stats.test.ts new file mode 100644 index 000000000..f8366efbf --- /dev/null +++ b/src/lib/day/__tests__/daily-stats.test.ts @@ -0,0 +1,81 @@ +/** + * One value per local day (v1.42, #613): a total for a cumulative type, the + * mean of hourly means for pulse, the plain mean otherwise, and one source + * per day through the ladder. + */ +import { describe, expect, it } from "vitest"; + +import { foldDailyStats } from "../daily-stats"; + +describe("daily stats", () => { + it("totals a cumulative type and keeps one source per day", () => { + const stats = foldDailyStats( + [ + { + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + day: "2026-03-29", + hour: 0, + total: 8000, + count: 1, + }, + { + type: "ACTIVITY_STEPS", + source: "WITHINGS", + day: "2026-03-29", + hour: 0, + total: 7000, + count: 1, + }, + ], + null, + ); + // One source, never the sum of two. + expect([8000, 7000]).toContain( + stats.get("ACTIVITY_STEPS")?.get("2026-03-29"), + ); + }); + + it("weighs every hour of pulse once", () => { + const stats = foldDailyStats( + [ + // A workout hour with many readings must not pull the day up. + { + type: "PULSE", + source: "APPLE_HEALTH", + day: "2026-03-29", + hour: 7, + total: 150 * 100, + count: 100, + }, + { + type: "PULSE", + source: "APPLE_HEALTH", + day: "2026-03-29", + hour: 12, + total: 60 * 2, + count: 2, + }, + ], + null, + ); + expect(stats.get("PULSE")?.get("2026-03-29")).toBe(105); + }); + + it("means a level type over its readings", () => { + const stats = foldDailyStats( + [ + { + type: "WEIGHT", + source: "MANUAL", + day: "2026-03-29", + hour: 0, + total: 160, + count: 2, + }, + ], + null, + ); + expect(stats.get("WEIGHT")?.get("2026-03-29")).toBe(80); + }); +}); diff --git a/src/lib/day/__tests__/model-projection.test.ts b/src/lib/day/__tests__/model-projection.test.ts new file mode 100644 index 000000000..e2154d4b9 --- /dev/null +++ b/src/lib/day/__tests__/model-projection.test.ts @@ -0,0 +1,121 @@ +/** + * A day leaving for a model (v1.42, #613): no life event, no note, no + * document name, and a switched-off module named as such. + */ +import { describe, expect, it } from "vitest"; + +import { MODEL_EXCLUDED_DAY_SECTIONS, type DayResponse } from "../contract"; +import { projectDayForModel } from "../model-projection"; + +const day: DayResponse = { + date: "2026-03-29", + tz: "Europe/Berlin", + counts: { values: 1, entries: 3 }, + running: [ + { + kind: "lifeEvent", + section: "lifeEvents", + id: "le1", + title: "Parental leave", + sub: "FAMILY", + since: "2026-03-01", + until: "2026-06-30", + dayIndex: 29, + dayCount: 122, + href: null, + }, + { + kind: "medication", + section: "medications", + id: "m1", + title: "Ramipril", + sub: "5 mg", + since: "2026-01-01", + until: null, + dayIndex: 88, + dayCount: null, + href: "/medications/m1", + }, + ], + values: [ + { + type: "WEIGHT", + value: 80, + unit: "kg", + at: "2026-03-29T06:00:00.000Z", + source: "MANUAL", + band: null, + }, + ], + events: [ + { + at: null, + kind: "lifeEvent", + section: "lifeEvents", + id: "le2", + title: "Grandmother died", + meta: "LOSS", + note: "private words", + docs: [], + href: null, + }, + { + at: "2026-03-29T09:00:00.000Z", + kind: "visit", + section: "visits", + id: "v1", + title: "Check-up", + meta: "ROUTINE", + note: "outcome text", + docs: [{ id: "d1", name: "scan-of-letter.pdf" }], + href: "/checkups?visit=v1", + }, + { + at: "2026-03-29T10:00:00.000Z", + kind: "mood", + section: "mood", + id: "mo1", + title: "GUT", + meta: "4", + note: "a note", + docs: [], + href: null, + }, + ], + notable: [], + sections: { cycle: { available: false, reason: "not_shared" } }, +}; + +describe("model projection", () => { + const projected = projectDayForModel(day, ["environment", "lifeEvents"]); + const text = JSON.stringify(projected); + + it("drops every model-excluded section, wherever it appears", () => { + for (const section of MODEL_EXCLUDED_DAY_SECTIONS) { + expect(text).not.toContain(`"${section}"`); + } + expect(text).not.toContain("Parental leave"); + expect(text).not.toContain("Grandmother"); + expect(projected.counts.entries).toBe(2); + }); + + it("drops notes and document names", () => { + expect(text).not.toContain("private words"); + expect(text).not.toContain("outcome text"); + expect(text).not.toContain("a note"); + expect(text).not.toContain("scan-of-letter"); + }); + + it("names a switched-off module and an unshared section", () => { + expect(projected.unavailable).toEqual([ + { section: "environment", reason: "module_disabled" }, + { section: "cycle", reason: "not_shared" }, + ]); + }); + + it("wraps the person's own words when asked to", () => { + const fenced = projectDayForModel(day, [], (v) => `[${v}]`); + expect(fenced.running[0].title).toBe("[Ramipril]"); + expect(fenced.events[0].title).toBe("[Check-up]"); + }); +}); diff --git a/src/lib/day/__tests__/notable.test.ts b/src/lib/day/__tests__/notable.test.ts new file mode 100644 index 000000000..37090d19a --- /dev/null +++ b/src/lib/day/__tests__/notable.test.ts @@ -0,0 +1,123 @@ +/** + * The notable-day rules (v1.42, #613): an extreme needs history and has to + * stand unmatched for three months, `since` is always a true date, and a gap + * needs a habit before it. + */ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; + +import { + EXTREME_MIN_HISTORY_DAYS, + EXTREME_MIN_SPAN_DAYS, + GAP_MIN_DAYS, + extremesInSeries, + gapsInSeries, +} from "../notable"; + +/** `n` consecutive days from `start`, each with `value(i)`. */ +function series( + start: string, + n: number, + value: (i: number) => number, +): Array<[string, number]> { + return Array.from({ length: n }, (_, i) => [ + shiftDateKey(start, i), + value(i), + ]); +} + +describe("extremes", () => { + it("marks a new high after months of lower days, since the first day seen", () => { + const days = series("2025-01-01", 200, () => 120); + days.push(["2025-07-20", 150]); + const found = extremesInSeries("BLOOD_PRESSURE_SYS", days, "2025-07-20"); + expect(found).toEqual([ + { + date: "2025-07-20", + kind: "extremeHigh", + type: "BLOOD_PRESSURE_SYS", + params: { since: "2025-01-01", value: 150 }, + }, + ]); + }); + + it("names the last day that was at least as high", () => { + const days = series("2024-01-01", 400, (i) => (i === 100 ? 160 : 120)); + days.push([shiftDateKey("2024-01-01", 400), 155]); + const [found] = extremesInSeries( + "BLOOD_PRESSURE_SYS", + days, + shiftDateKey("2024-01-01", 400), + ); + expect(found.kind).toBe("extremeHigh"); + expect(found.params.since).toBe(shiftDateKey("2024-01-01", 100)); + }); + + it("stays quiet when an equal day is less than three months back", () => { + const days = series("2025-01-01", 200, (i) => (i === 180 ? 150 : 120)); + days.push([shiftDateKey("2025-01-01", 200), 150]); + expect( + extremesInSeries( + "BLOOD_PRESSURE_SYS", + days, + shiftDateKey("2025-01-01", 200), + ), + ).toEqual([]); + }); + + it("needs at least thirty days of history", () => { + const days = series("2025-01-01", EXTREME_MIN_HISTORY_DAYS - 1, () => 70); + // Spread out so the span rule alone would pass. + const sparse = days.map( + ([, v], i) => [shiftDateKey("2024-01-01", i * 5), v] as [string, number], + ); + sparse.push(["2025-06-01", 90]); + expect(extremesInSeries("WEIGHT", sparse, "2025-06-01")).toEqual([]); + }); + + it("marks a new low the same way", () => { + const days = series("2025-01-01", EXTREME_MIN_SPAN_DAYS + 40, () => 80); + const last = shiftDateKey("2025-01-01", EXTREME_MIN_SPAN_DAYS + 40); + days.push([last, 75]); + const [found] = extremesInSeries("WEIGHT", days, last); + expect(found).toMatchObject({ kind: "extremeLow", params: { value: 75 } }); + }); + + it("reports nothing for days before the window", () => { + const days = series("2025-01-01", 200, (i) => i); + expect(extremesInSeries("WEIGHT", days, "2026-01-01")).toEqual([]); + }); +}); + +describe("gaps", () => { + it("dates a gap on its first missing day after a habit", () => { + const days = new Set(series("2025-01-01", 40, () => 1).map(([d]) => d)); + const found = gapsInSeries("WEIGHT", days, "2025-01-01", "2025-03-31"); + expect(found).toEqual([ + { + date: "2025-02-10", + kind: "gap", + type: "WEIGHT", + params: { days: 50 }, + }, + ]); + }); + + it("needs a habit before the gap", () => { + const days = new Set(["2025-01-01", "2025-01-08", "2025-01-15"]); + expect(gapsInSeries("WEIGHT", days, "2025-01-01", "2025-03-31")).toEqual( + [], + ); + }); + + it("ignores a pause shorter than two weeks", () => { + const all = series("2025-01-01", 90, () => 1).map(([d]) => d); + const days = new Set( + all.filter((_, i) => i < 40 || i >= 40 + GAP_MIN_DAYS - 1), + ); + expect(gapsInSeries("WEIGHT", days, "2025-01-01", "2025-03-31")).toEqual( + [], + ); + }); +}); diff --git a/src/lib/day/__tests__/sections.test.ts b/src/lib/day/__tests__/sections.test.ts new file mode 100644 index 000000000..6e3015738 --- /dev/null +++ b/src/lib/day/__tests__/sections.test.ts @@ -0,0 +1,107 @@ +/** + * Which sections of a day a caller sees (v1.42, #613): a switched-off module + * leaves without a word, a section the grant does not cover is named once, + * and the environment rows are the owner's alone. + */ +import { describe, expect, it } from "vitest"; + +import { DAY_SECTION_KEYS } from "@/lib/day/contract"; +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; + +import { + DAY_SECTION_SHARE_DOMAIN, + measurementTypeVisible, + resolveDayAccessFrom, +} from "../sections"; + +const ALL_ON = Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record< + ModuleKey, + boolean +>; + +describe("day access", () => { + it("opens every section to the owner", () => { + const access = resolveDayAccessFrom({ + modules: ALL_ON, + domainVisible: () => true, + owner: true, + }); + expect([...access.readable].sort()).toEqual([...DAY_SECTION_KEYS].sort()); + expect(access.notShared).toEqual([]); + }); + + it("leaves a switched-off module's section out without naming it", () => { + const access = resolveDayAccessFrom({ + modules: { ...ALL_ON, mood: false, timeline: false }, + domainVisible: () => true, + owner: true, + }); + expect(access.readable.has("mood")).toBe(false); + expect(access.readable.has("lifeEvents")).toBe(false); + expect(access.notShared).toEqual([]); + expect(access.moduleOff).toEqual( + expect.arrayContaining(["mood", "lifeEvents"]), + ); + }); + + it("reads only the domains a scoped grant covers and names the rest", () => { + const access = resolveDayAccessFrom({ + modules: ALL_ON, + domainVisible: (d) => d === "measurements", + owner: false, + }); + for (const section of DAY_SECTION_KEYS) { + const domain = DAY_SECTION_SHARE_DOMAIN[section]; + expect(access.readable.has(section), section).toBe( + domain === "measurements", + ); + } + expect(access.notShared).toEqual( + expect.arrayContaining([ + "medications", + "mood", + "lifeEvents", + "environment", + ]), + ); + }); + + it("keeps the environment rows for the owner even under a full grant", () => { + const access = resolveDayAccessFrom({ + modules: ALL_ON, + domainVisible: () => true, + owner: false, + }); + expect(access.readable.has("environment")).toBe(false); + expect(access.notShared).toEqual(["environment"]); + }); + + it("maps every section to a real sharing domain or to the owner", () => { + for (const section of DAY_SECTION_KEYS) { + const domain = DAY_SECTION_SHARE_DOMAIN[section]; + if (domain !== null) expect(SHARE_DOMAINS).toContain(domain); + } + expect(DAY_SECTION_SHARE_DOMAIN.lifeEvents).toBe("profile"); + }); + + it("narrows readings type by type through their own module", () => { + expect(measurementTypeVisible("WEIGHT", { ...ALL_ON })).toBe(true); + expect( + measurementTypeVisible("HEART_RATE_VARIABILITY", { + ...ALL_ON, + recovery: false, + }), + ).toBe(false); + }); +}); + +describe("life-event coverage", () => { + it("reaches to the end of a coarse date", async () => { + const { lastDayCovered } = await import("@/lib/life-events/dates"); + expect(lastDayCovered("2023-09-14", "DAY")).toBe("2023-09-14"); + expect(lastDayCovered("2024-02-01", "MONTH")).toBe("2024-02-29"); + expect(lastDayCovered("2023-12-01", "MONTH")).toBe("2023-12-31"); + expect(lastDayCovered("2019-01-01", "YEAR")).toBe("2019-12-31"); + }); +}); diff --git a/src/lib/day/__tests__/values.test.ts b/src/lib/day/__tests__/values.test.ts new file mode 100644 index 000000000..e6fd91123 --- /dev/null +++ b/src/lib/day/__tests__/values.test.ts @@ -0,0 +1,85 @@ +/** + * How a day's readings become entries (v1.42, #613). + */ +import { describe, expect, it } from "vitest"; + +import type { DayReadingRow, DayReadings } from "@/lib/mood/linked-context"; + +import { + MAX_READINGS_PER_TYPE, + dayValueShape, + shapeDayValues, +} from "../values"; + +function row( + type: DayReadingRow["type"], + value: number, + iso: string, + unit = "x", +): DayReadingRow { + return { + type, + value, + unit, + measuredAt: new Date(iso), + source: "MANUAL", + deviceType: null, + }; +} + +describe("day values", () => { + it("keeps hand readings, totals cumulative types, folds dense ones", () => { + expect(dayValueShape("BLOOD_PRESSURE_SYS", 2)).toBe("readings"); + expect(dayValueShape("ACTIVITY_STEPS", 1)).toBe("total"); + expect(dayValueShape("PULSE", 3)).toBe("dayValue"); + expect(dayValueShape("BLOOD_GLUCOSE", MAX_READINGS_PER_TYPE + 1)).toBe( + "dayValue", + ); + }); + + it("shapes entries with the band and the night", () => { + const readings: DayReadings = { + rowsByType: new Map([ + [ + "BLOOD_PRESSURE_SYS", + [ + row("BLOOD_PRESSURE_SYS", 121, "2026-03-29T06:00:00Z", "mmHg"), + row("BLOOD_PRESSURE_SYS", 131, "2026-03-29T18:00:00Z", "mmHg"), + ], + ], + [ + "ACTIVITY_STEPS", + [ + row("ACTIVITY_STEPS", 4000, "2026-03-29T12:00:00Z", "steps"), + row("ACTIVITY_STEPS", 1000, "2026-03-29T20:00:00Z", "steps"), + ], + ], + ]), + night: { + night: "2026-03-29", + measuredAt: new Date("2026-03-29T05:00:00Z"), + asleepMinutes: 420, + inBedMinutes: null, + awakeMinutes: null, + stages: {}, + sourceDiscrepancy: null, + } as unknown as DayReadings["night"], + nightSource: "APPLE_HEALTH", + }; + const band = { lo: 110, hi: 135, n: 30 }; + const values = shapeDayValues( + readings, + new Map([["BLOOD_PRESSURE_SYS", band]]), + true, + ); + expect(values.map((v) => [v.type, v.value])).toEqual([ + ["SLEEP_DURATION", 420], + ["BLOOD_PRESSURE_SYS", 121], + ["BLOOD_PRESSURE_SYS", 131], + ["ACTIVITY_STEPS", 5000], + ]); + expect(values[1].band).toEqual(band); + expect(values[3].at).toBe("2026-03-29T20:00:00.000Z"); + expect(shapeDayValues(readings, new Map(), false)).toHaveLength(3); + }); +}); diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index b92875565..500d8d2f2 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -13,6 +13,7 @@ * * GET /api/day/{date} one local day across the record * GET /api/day/index which days hold anything, for a window + * GET /api/day/notable what changed since the last visit * GET /api/timeline lanes, standing items and value series * GET /api/timeline/readiness what the timeline can already show * GET /api/life-events the record's life events @@ -39,6 +40,7 @@ import type { z } from "zod/v4"; import type { dayIndexResponseSchema, + dayNotableResponseSchema, dayResponseSchema, lifeEventCreateSchema, lifeEventListResponseSchema, @@ -342,6 +344,16 @@ export type DayNotable = DayResponse["notable"][number]; /** `GET /api/day/index` → `data`. */ export type DayIndexResponse = z.infer; +/** + * `GET /api/day/notable` → `data`: the observations and context changes + * since the last visit, for the visit preparation. + */ +export type DayNotableWindowResponse = z.infer; +export type DayChange = DayNotableWindowResponse["changes"][number]; + +/** Widest window `GET /api/day/notable` answers, in days. */ +export const DAY_NOTABLE_MAX_SPAN_DAYS = 1096; + /** `GET /api/timeline` query and `data`. */ export type TimelineQuery = z.infer; export type TimelineResponse = z.infer; diff --git a/src/lib/day/daily-stats.ts b/src/lib/day/daily-stats.ts new file mode 100644 index 000000000..0645b57dc --- /dev/null +++ b/src/lib/day/daily-stats.ts @@ -0,0 +1,182 @@ +/** + * One number per local day and type, read live (v1.42, #613). + * + * The usual range under a reading, the notable-day rules and the day index + * all compare days with each other, and they have to compare the same days + * the day view shows. The rollup tier cuts days at UTC midnight, so a reading + * at 00:30 in Berlin sits in the day before there; this reader groups by the + * record's own local date in SQL instead (`measured_at` is stored as UTC + * wall time, so it is read `AT TIME ZONE 'UTC'` first and then into the + * record's zone). + * + * The day value follows the type: a cumulative type (steps, energy, distance) + * is the day's total, a pulse-like type is the mean of its hourly means (a + * workout hour must not outweigh the rest of the day, `day-statistic.ts`), + * everything else is the mean of the day's readings. Each day keeps one + * source, picked through the record's source ladder exactly as the rollup + * readers pick it. + */ +import { + Prisma, + type MeasurementSource, + type MeasurementType, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; +import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic"; +import { dateOnlyAtNoonUtc, dateOnlyKey } from "@/lib/tz/date-only"; +import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read"; + +/** Day key → the day's value, per type. */ +export type DailyStats = Map>; + +interface StatRow { + type: MeasurementType; + source: MeasurementSource; + day: string; + hour: number; + total: number; + count: number; +} + +interface DaySourceCell { + bucketStart: Date; + source: MeasurementSource; + count: number; + value: number; +} + +/** + * The day value of each type on each local day in `[from, to)`. + * + * `from` and `to` are instants (the start of the first local day and the + * start of the day after the last), so the window itself is local too. + */ +export async function readLocalDailyStats(args: { + userId: string; + types: readonly MeasurementType[]; + from: Date; + to: Date; + tz: string; + priorityJson: unknown; +}): Promise { + const out: DailyStats = new Map(); + if (args.types.length === 0 || args.from >= args.to) return out; + const hourlyTypes = args.types.filter((t) => HOURLY_MEAN_DAY_TYPES.has(t)); + // Every value is parameter-bound; the enum casts are literal SQL. The hour + // column is only split out for the pulse-like types, so a dense type of any + // other kind still comes back as one row per day and source. + const hourExpr = + hourlyTypes.length > 0 + ? Prisma.sql`CASE WHEN "type" = ANY(${hourlyTypes}::"measurement_type"[]) + THEN EXTRACT(HOUR FROM ("measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${args.tz})::int + ELSE 0 END` + : Prisma.sql`0`; + const rows = await prisma.$queryRaw` + SELECT "type"::text AS "type", + "source"::text AS "source", + to_char(("measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${args.tz}, 'YYYY-MM-DD') AS "day", + ${hourExpr} AS "hour", + SUM("value")::float8 AS "total", + COUNT(*)::int AS "count" + FROM "measurements" + WHERE "user_id" = ${args.userId} + AND "deleted_at" IS NULL + AND "type" = ANY(${[...args.types]}::"measurement_type"[]) + AND "measured_at" >= ${args.from} + AND "measured_at" < ${args.to} + GROUP BY 1, 2, 3, 4 + `; + return foldDailyStats(rows, args.priorityJson); +} + +/** + * Fold the grouped rows into one value per type and day. Exported for the + * unit tests, which pin the three day statistics and the source pick without + * a database. + */ +export function foldDailyStats( + rows: readonly StatRow[], + priorityJson: unknown, +): DailyStats { + // type → day → source → hour cells + const nested = new Map< + MeasurementType, + Map> + >(); + for (const row of rows) { + let days = nested.get(row.type); + if (!days) nested.set(row.type, (days = new Map())); + let sources = days.get(row.day); + if (!sources) days.set(row.day, (sources = new Map())); + let cells = sources.get(row.source); + if (!cells) sources.set(row.source, (cells = [])); + cells.push({ total: Number(row.total), count: Number(row.count) }); + } + + const out: DailyStats = new Map(); + for (const [type, days] of nested) { + const cumulative = isCumulativeDaySumType(type); + const hourly = HOURLY_MEAN_DAY_TYPES.has(type); + const cells: DaySourceCell[] = []; + for (const [day, sources] of days) { + for (const [source, hours] of sources) { + const count = hours.reduce((n, h) => n + h.count, 0); + if (count === 0) continue; + let value: number; + if (cumulative) { + value = hours.reduce((s, h) => s + h.total, 0); + } else if (hourly) { + value = + hours.reduce((s, h) => s + h.total / h.count, 0) / hours.length; + } else { + value = hours.reduce((s, h) => s + h.total, 0) / count; + } + cells.push({ + bucketStart: dateOnlyAtNoonUtc(day), + source, + count, + value, + }); + } + } + const picked = collapseRollupRowsBySource(cells, type, priorityJson); + const perDay = new Map(); + for (const cell of picked) { + perDay.set(dateOnlyKey(cell.bucketStart), cell.value); + } + out.set(type, perDay); + } + return out; +} + +/** + * The local days in `[from, to)` that hold a reading of any of `types`. + * Distinct in SQL, so a dense type costs a scan and not a transfer. + * `shiftHours` files a reading under the day `shiftHours` later (the day + * index files a late-evening sleep stage under the night it belongs to). + */ +export async function readLocalDaysWithReadings(args: { + userId: string; + types: readonly MeasurementType[]; + from: Date; + to: Date; + tz: string; + shiftHours?: number; +}): Promise> { + const out = new Set(); + if (args.types.length === 0 || args.from >= args.to) return out; + const shiftHours = args.shiftHours ?? 0; + const shift = `${shiftHours} hours`; + const rows = await prisma.$queryRaw>` + SELECT DISTINCT to_char((("measured_at" + ${shift}::interval) AT TIME ZONE 'UTC') AT TIME ZONE ${args.tz}, 'YYYY-MM-DD') AS "day" + FROM "measurements" + WHERE "user_id" = ${args.userId} + AND "deleted_at" IS NULL + AND "type" = ANY(${[...args.types]}::"measurement_type"[]) + AND "measured_at" >= ${new Date(args.from.getTime() - shiftHours * 3_600_000)} + AND "measured_at" < ${new Date(args.to.getTime() - shiftHours * 3_600_000)} + `; + for (const row of rows) out.add(row.day); + return out; +} diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts new file mode 100644 index 000000000..fc6e7ce78 --- /dev/null +++ b/src/lib/day/day-index.ts @@ -0,0 +1,463 @@ +/** + * Which days of a window hold anything, by section (v1.42, #613). + * + * Feeds the row of day dots under a chart and tells a list which of its dates + * open a day with content. Dates and section keys only: no value, no title, + * no free text. Bounded to at most `DAY_INDEX_MAX_SPAN_DAYS` days, and each + * section reads one column of its own table over the window. + * + * Only what happens on a day marks it. Something that runs through days (a + * medication, an illness, a trip) would mark every one of them and say + * nothing; its start and end mark theirs. The weather row exists for every + * day and marks none. + * + * The sleep dot is a close reading: a night is filed under the day it woke + * on, and the index takes a stage row ending after 18:00 as part of the next + * night. The day itself reconstructs the night exactly. + */ +import { + MeasurementType, + type MeasurementType as MeasurementTypeValue, +} from "@/generated/prisma/enums"; + +import { + DAY_SECTION_KEYS, + type DayIndexResponse, + type DaySectionKey, +} from "@/lib/day/contract"; +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 { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import { effectiveMoodTz } from "@/lib/mood/date-key"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +type DayMarks = Map>; + +function mark( + marks: DayMarks, + day: string, + section: DaySectionKey, + from: string, + to: string, +): void { + if (day < from || day > to) return; + let set = marks.get(day); + if (!set) marks.set(day, (set = new Set())); + set.add(section); +} + +interface IndexFrame { + userId: string; + from: string; + to: string; + tz: string; + start: Date; + end: Date; + marks: DayMarks; +} + +/** Mark every instant's local day. */ +function markInstants( + frame: IndexFrame, + section: DaySectionKey, + instants: ReadonlyArray, +): void { + for (const at of instants) { + if (at) + mark( + frame.marks, + userDayKey(at, frame.tz), + section, + frame.from, + frame.to, + ); + } +} + +async function measurementDays( + frame: IndexFrame, + types: readonly MeasurementTypeValue[], + section: DaySectionKey, + shiftHours: number, +): Promise { + const days = await readLocalDaysWithReadings({ + userId: frame.userId, + types, + from: frame.start, + to: frame.end, + tz: frame.tz, + shiftHours, + }); + for (const day of days) mark(frame.marks, day, section, frame.from, frame.to); +} + +const instantWindow = (frame: IndexFrame) => ({ + gte: frame.start, + lt: frame.end, +}); + +/** The per-section readers of the index. */ +const INDEX_READERS: Partial< + Record Promise> +> = { + async medications(frame) { + const startKey = dayKeyAsUtcMidnight(frame.from); + const endKey = dayKeyAsUtcMidnight(frame.to); + const [intakes, changes, meds, courses, pauses] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + skipped: false, + takenAt: instantWindow(frame), + ...TRACKED_INTAKE_EVENT_WHERE, + }, + select: { takenAt: true }, + }), + prisma.medicationDoseChange.findMany({ + where: { + medication: { userId: frame.userId }, + effectiveFrom: instantWindow(frame), + }, + select: { effectiveFrom: true }, + }), + prisma.medication.findMany({ + where: { + userId: frame.userId, + OR: [ + { startsOn: { gte: startKey, lte: endKey } }, + { endsOn: { gte: startKey, lte: endKey } }, + ], + }, + select: { startsOn: true, endsOn: true }, + }), + prisma.medicationCourse.findMany({ + where: { + userId: frame.userId, + OR: [ + { startsOn: { gte: startKey, lte: endKey } }, + { endsOn: { gte: startKey, lte: endKey } }, + ], + }, + select: { startsOn: true, endsOn: true }, + }), + prisma.medicationPauseEra.findMany({ + where: { + userId: frame.userId, + OR: [ + { pausedAt: instantWindow(frame) }, + { resumedAt: instantWindow(frame) }, + ], + }, + select: { pausedAt: true, resumedAt: true }, + }), + ]); + markInstants( + frame, + "medications", + intakes.map((r) => r.takenAt), + ); + markInstants( + frame, + "medications", + changes.map((r) => r.effectiveFrom), + ); + markInstants( + frame, + "medications", + pauses.flatMap((r) => [r.pausedAt, r.resumedAt]), + ); + for (const row of [...meds, ...courses]) { + for (const date of [row.startsOn, row.endsOn]) { + if (date) + mark( + frame.marks, + dateOnlyKey(date), + "medications", + frame.from, + frame.to, + ); + } + } + }, + async illness(frame) { + const [episodes, logs] = await Promise.all([ + prisma.illnessEpisode.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + OR: [ + { onsetAt: instantWindow(frame) }, + { resolvedAt: instantWindow(frame) }, + ], + }, + select: { onsetAt: true, resolvedAt: true }, + }), + prisma.illnessDayLog.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + date: { gte: frame.from, lte: frame.to }, + }, + select: { date: true }, + }), + ]); + markInstants( + frame, + "illness", + episodes.flatMap((r) => [r.onsetAt, r.resolvedAt]), + ); + for (const log of logs) + mark(frame.marks, log.date, "illness", frame.from, frame.to); + }, + async symptoms(frame) { + const rows = await prisma.symptomEvent.findMany({ + where: { userId: frame.userId, occurredAt: instantWindow(frame) }, + select: { occurredAt: true }, + }); + markInstants( + frame, + "symptoms", + rows.map((r) => r.occurredAt), + ); + }, + async allergies(frame) { + const rows = await prisma.allergy.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + onsetAt: instantWindow(frame), + }, + select: { onsetAt: true }, + }); + markInstants( + frame, + "allergies", + rows.map((r) => r.onsetAt), + ); + }, + async labs(frame) { + const rows = await prisma.labResult.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + takenAt: instantWindow(frame), + }, + select: { takenAt: true }, + }); + markInstants( + frame, + "labs", + rows.map((r) => r.takenAt), + ); + }, + async visits(frame) { + const rows = await prisma.encounter.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + status: { in: ["DONE", "PLANNED"] }, + occurredAt: instantWindow(frame), + }, + select: { occurredAt: true }, + }); + markInstants( + frame, + "visits", + rows.map((r) => r.occurredAt), + ); + }, + async vaccinations(frame) { + const rows = await prisma.vaccinationRecord.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + occurredAt: instantWindow(frame), + }, + select: { occurredAt: true }, + }); + markInstants( + frame, + "vaccinations", + rows.map((r) => r.occurredAt), + ); + }, + async checkups(frame) { + const rows = await prisma.measurementReminderEvent.findMany({ + where: { + userId: frame.userId, + kind: "SATISFIED", + occurredAt: instantWindow(frame), + reminder: { deletedAt: null, origin: "VORSORGE" }, + }, + select: { occurredAt: true }, + }); + markInstants( + frame, + "checkups", + rows.map((r) => r.occurredAt), + ); + }, + async documents(frame) { + const start = dayKeyAsUtcMidnight(frame.from); + const end = dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)); + const rows = await prisma.inboundDocument.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + OR: [ + { reportDate: { gte: start, lt: end } }, + { reportDate: null, documentDate: { gte: start, lt: end } }, + ], + }, + select: { reportDate: true, documentDate: true }, + }); + for (const row of rows) { + const date = row.reportDate ?? row.documentDate; + if (date) + mark(frame.marks, dateOnlyKey(date), "documents", frame.from, frame.to); + } + }, + async mood(frame) { + const rows = await prisma.moodEntry.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + date: { gte: frame.from, lte: frame.to }, + }, + select: { date: true }, + distinct: ["date"], + }); + for (const row of rows) + mark(frame.marks, row.date, "mood", frame.from, frame.to); + }, + async assessments(frame) { + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + takenAt: { + gte: new Date(frame.start.getTime() - 14 * 3_600_000), + lt: new Date(frame.end.getTime() + 14 * 3_600_000), + }, + }, + select: { takenAt: true, tz: true }, + }); + for (const row of rows) { + const tz = row.tz ? effectiveMoodTz(row) : frame.tz; + mark( + frame.marks, + userDayKey(row.takenAt, tz), + "assessments", + frame.from, + frame.to, + ); + } + }, + async workouts(frame) { + const rows = await prisma.workout.findMany({ + where: { userId: frame.userId, startedAt: instantWindow(frame) }, + select: { startedAt: true }, + }); + markInstants( + frame, + "workouts", + rows.map((r) => r.startedAt), + ); + }, + async cycle(frame) { + const rows = await prisma.cycleDayLog.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + date: { gte: frame.from, lte: frame.to }, + }, + select: { date: true }, + }); + for (const row of rows) + mark(frame.marks, row.date, "cycle", frame.from, frame.to); + }, + async lifeEvents(frame) { + const rows = await prisma.lifeEvent.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + precision: "DAY", + startDate: { gte: frame.from, lte: frame.to }, + }, + select: { startDate: true }, + }); + for (const row of rows) + mark(frame.marks, row.startDate, "lifeEvents", frame.from, frame.to); + }, +}; + +export async function loadDayIndex(args: { + recordId: string; + from: string; + to: string; + access: DayAccess; + tz: string; +}): Promise { + const { recordId, from, to, access, tz } = args; + const frame: IndexFrame = { + userId: recordId, + from, + to, + tz, + start: startOfLocalDayKey(from, tz), + end: startOfLocalDayKey(shiftDateKey(to, 1), tz), + marks: new Map(), + }; + const visible = (type: MeasurementTypeValue) => + measurementTypeVisible(type, access.modules); + const valueTypes = access.readable.has("values") + ? Object.values(MeasurementType).filter( + (t) => t !== "SLEEP_DURATION" && visible(t), + ) + : []; + const sleepTypes = + access.readable.has("sleep") && visible("SLEEP_DURATION") + ? (["SLEEP_DURATION"] as MeasurementTypeValue[]) + : []; + + const readers = [...access.readable].flatMap((section) => { + const reader = INDEX_READERS[section]; + return reader ? [reader(frame)] : []; + }); + const [, , , notable] = await Promise.all([ + measurementDays(frame, valueTypes, "values", 0), + measurementDays(frame, sleepTypes, "sleep", 6), + Promise.all(readers), + valueTypes.length === 0 + ? Promise.resolve([]) + : loadUserSourcePriority(recordId).then((priorityJson) => + loadNotableRange({ + userId: recordId, + from, + to, + tz, + priorityJson, + typeVisible: (t) => valueTypes.includes(t), + gaps: false, + }), + ), + ]); + + const days: DayIndexResponse["days"] = {}; + for (const day of [...frame.marks.keys()].sort()) { + const held = frame.marks.get(day) ?? new Set(); + days[day] = DAY_SECTION_KEYS.filter((section) => held.has(section)); + } + return { + from, + to, + days, + notable: [...new Set(notable.map((n) => n.date))].sort(), + }; +} diff --git a/src/lib/day/load-day.ts b/src/lib/day/load-day.ts new file mode 100644 index 000000000..fa261a042 --- /dev/null +++ b/src/lib/day/load-day.ts @@ -0,0 +1,162 @@ +/** + * `loadDay`: one local day across the record, for every reader (v1.42, #613). + * + * The web view, the native client, the Coach and the MCP endpoint all read a + * day through this function, so they cannot disagree about what a Tuesday + * held. The readings come live from `measurements` inside the local window + * (never from the rollups, which cut at UTC), the record sections from their + * own tables, each bounded to the day and indexed on its time field, and all + * of them in parallel. + * + * What the caller may see is decided before anything is read + * (`DayAccess`): a section of a switched-off module is not read and not + * mentioned, a section the grant does not cover is not read and is named + * once in `sections`. Nothing here writes, and nothing here talks to a model; + * `model-projection.ts` is the one way a day leaves for one. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import type { DayEvent, DayResponse, DayRunningItem } from "@/lib/day/contract"; +import { loadNotableRange } from "@/lib/day/notable"; +import { + RECORD_SECTION_READERS, + type DayFrame, + type SectionPart, +} from "@/lib/day/records"; +import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; +import { readDayBands, shapeDayValues } from "@/lib/day/values"; +import { + resolveDayReadings, + type DayReadings, +} from "@/lib/mood/linked-context"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { localDayWindow } from "@/lib/tz/local-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +const NO_READINGS: DayReadings = { + rowsByType: new Map(), + night: null, + nightSource: null, +}; + +/** Date-only entries first, then by time; ties keep the section order. */ +function byTime(a: DayEvent, b: DayEvent): number { + if (a.at === b.at) return 0; + if (a.at === null) return -1; + if (b.at === null) return 1; + return a.at.localeCompare(b.at); +} + +function bySince(a: DayRunningItem, b: DayRunningItem): number { + return a.since.localeCompare(b.since) || a.title.localeCompare(b.title); +} + +export interface LoadDayArgs { + /** The record's user id (the resolved user, never the actor). */ + recordId: string; + day: string; + access: DayAccess; + /** The record's zone; resolved when absent. */ + tz?: string; + /** The earliest instant a comparison may read (a lookback limit). */ + floor?: Date | null; +} + +export async function loadDay(args: LoadDayArgs): Promise { + const { recordId, day, access } = args; + const tz = args.tz ?? (await resolveUserTimezone(recordId)); + const { dayStart, dayEnd } = localDayWindow(day, tz); + const valuesReadable = access.readable.has("values"); + const sleepReadable = access.readable.has("sleep"); + + const typeVisible = (type: MeasurementType): boolean => { + if (!measurementTypeVisible(type, access.modules)) return false; + return type === "SLEEP_DURATION" ? sleepReadable : valuesReadable; + }; + + const frame: DayFrame = { + userId: recordId, + day, + tz, + dayStart, + dayEnd, + documentsReadable: access.readable.has("documents"), + }; + + const priority = + valuesReadable || sleepReadable + ? await loadUserSourcePriority(recordId) + : null; + + const recordSections = [...access.readable].filter( + (section) => RECORD_SECTION_READERS[section] !== undefined, + ); + const [readings, parts] = await Promise.all([ + valuesReadable || sleepReadable + ? resolveDayReadings( + recordId, + day, + tz, + { dayStart, dayEnd }, + typeVisible, + priority, + ) + : Promise.resolve(NO_READINGS), + Promise.all( + recordSections.map((section) => + ( + RECORD_SECTION_READERS[section] as ( + frame: DayFrame, + ) => Promise + )(frame), + ), + ), + ]); + + const presentTypes = [...readings.rowsByType.keys()]; + const [bands, notable] = + presentTypes.length === 0 + ? [new Map(), []] + : await Promise.all([ + readDayBands({ + userId: recordId, + day, + tz, + types: presentTypes, + priorityJson: priority, + floor: args.floor ?? null, + }), + loadNotableRange({ + userId: recordId, + from: day, + to: day, + tz, + priorityJson: priority, + typeVisible, + gaps: false, + extremeTypes: presentTypes, + firstValueTypes: presentTypes, + floor: args.floor ?? null, + }), + ]); + + const values = shapeDayValues(readings, bands, sleepReadable); + const running = parts.flatMap((p) => p.running).sort(bySince); + const events = parts.flatMap((p) => p.events).sort(byTime); + + const sections: DayResponse["sections"] = {}; + for (const section of access.notShared) { + sections[section] = { available: false, reason: "not_shared" }; + } + + return { + date: day, + tz, + counts: { values: values.length, entries: events.length }, + running, + values, + events, + notable: notable.map(({ kind, type, params }) => ({ kind, type, params })), + sections, + }; +} diff --git a/src/lib/day/model-projection.ts b/src/lib/day/model-projection.ts new file mode 100644 index 000000000..609148332 --- /dev/null +++ b/src/lib/day/model-projection.ts @@ -0,0 +1,125 @@ +/** + * The one way a day leaves for a model (v1.42, #613). + * + * The Coach and the MCP endpoint read a day through `get_day`, and both go + * through this projection. It does three things and nothing else: + * + * - drops every section in {@link MODEL_EXCLUDED_DAY_SECTIONS} (life events + * in v1.42), wherever it appears: running items, events and the section + * list. The person's life events never reach a model, not even as a + * count; + * - drops the person's notes and the names of filed documents. A model + * reads what the day held, not what the person wrote about it; the notes + * have their own, separately fenced reads where a feature needs them; + * - names a switched-off module's section as `module_disabled`, so a model + * says "that module is off" instead of "nothing happened". + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import { + MODEL_EXCLUDED_DAY_SECTIONS, + type DayResponse, + type DaySectionKey, +} from "@/lib/day/contract"; + +const EXCLUDED: ReadonlySet = new Set( + MODEL_EXCLUDED_DAY_SECTIONS, +); + +export interface ModelDayRunning { + kind: DayResponse["running"][number]["kind"]; + section: DaySectionKey; + title: string; + sub: string | null; + since: string; + until: string | null; + dayIndex: number | null; + dayCount: number | null; +} + +export interface ModelDayEvent { + at: string | null; + kind: DayResponse["events"][number]["kind"]; + section: DaySectionKey; + title: string; + meta: string | null; +} + +export interface ModelDay { + date: string; + counts: { values: number; entries: number }; + running: ModelDayRunning[]; + values: Array<{ + type: string; + value: number; + unit: string; + at: string; + band: { lo: number; hi: number; n: number } | null; + }>; + events: ModelDayEvent[]; + notable: DayResponse["notable"]; + /** Sections the reader does not get, and why. Never a model-excluded one. */ + unavailable: Array<{ + section: DaySectionKey; + reason: "module_disabled" | "not_shared"; + }>; +} + +/** Whether a section may reach a model at all. */ +export function isModelVisibleSection(section: DaySectionKey): boolean { + return !EXCLUDED.has(section); +} + +export function projectDayForModel( + day: DayResponse, + moduleOff: readonly DaySectionKey[], + text: (value: string) => string = (value) => value, +): ModelDay { + const running = day.running + .filter((item) => isModelVisibleSection(item.section)) + .map((item) => ({ + kind: item.kind, + section: item.section, + title: text(item.title), + sub: item.sub === null ? null : text(item.sub), + since: item.since, + until: item.until, + dayIndex: item.dayIndex, + dayCount: item.dayCount, + })); + const events = day.events + .filter((item) => isModelVisibleSection(item.section)) + .map((item) => ({ + at: item.at, + kind: item.kind, + section: item.section, + title: text(item.title), + meta: item.meta, + })); + const unavailable: ModelDay["unavailable"] = []; + for (const section of moduleOff) { + if (isModelVisibleSection(section)) { + unavailable.push({ section, reason: "module_disabled" }); + } + } + for (const key of Object.keys(day.sections) as DaySectionKey[]) { + if (isModelVisibleSection(key)) { + unavailable.push({ section: key, reason: "not_shared" }); + } + } + return { + date: day.date, + counts: { values: day.values.length, entries: events.length }, + running, + values: day.values.map((v) => ({ + type: v.type as MeasurementType, + value: v.value, + unit: v.unit, + at: v.at, + band: v.band, + })), + events, + notable: day.notable, + unavailable, + }; +} diff --git a/src/lib/day/notable.ts b/src/lib/day/notable.ts new file mode 100644 index 000000000..3f3513a1d --- /dev/null +++ b/src/lib/day/notable.ts @@ -0,0 +1,260 @@ +/** + * Quiet, deterministic observations about days (v1.42, #613). + * + * The rules describe and never interpret. "Highest daily value since + * 3 March 2025" is a fact the record proves; "because of the new dose" is a + * claim it cannot, and nothing here makes one. Each observation is a key + * with parameters; the wording lives in the message bundles. They are shown + * only where a person already looks (the dots under a chart, one line in the + * day, the list before the next visit) and never pushed. + * + * - **extremeHigh / extremeLow**: the day's value of a core type is the + * highest (lowest) for at least {@link EXTREME_MIN_SPAN_DAYS} days, with + * at least {@link EXTREME_MIN_HISTORY_DAYS} days of history before it. + * `since` is the last earlier day that was at least as high (low), or the + * first day the comparison saw. Either way the sentence is true. + * - **firstValue**: the first reading of a type the record holds. + * - **gap**: a type that came on at least {@link GAP_MIN_ACTIVE_DAYS} of + * the {@link GAP_LEAD_DAYS} days before and then stopped for at least + * {@link GAP_MIN_DAYS} days. Only in the visit preparation, where a gap + * is useful to know about, and never in the day itself. + * + * Only core types are compared for extremes: the vitals the dashboard is + * built around. A watch's background figures would mark a day every week. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import type { DayNotable } from "@/lib/day/contract"; +import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats"; +import { prisma } from "@/lib/db"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** The types extremes are computed for. */ +export const NOTABLE_EXTREME_TYPES: readonly MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "RESTING_HEART_RATE", + "BLOOD_GLUCOSE", + "HEART_RATE_VARIABILITY", +]; + +/** Days of history an extreme needs before it is called one. */ +export const EXTREME_MIN_HISTORY_DAYS = 30; +/** How long a value has to stand unmatched to be an extreme. */ +export const EXTREME_MIN_SPAN_DAYS = 90; +/** How far back the comparison looks. */ +export const NOTABLE_LOOKBACK_DAYS = 730; +/** A gap is at least this many days without the type. */ +export const GAP_MIN_DAYS = 14; +/** The days before a gap that show the type was a habit. */ +export const GAP_LEAD_DAYS = 28; +/** Three readings a week over {@link GAP_LEAD_DAYS}. */ +export const GAP_MIN_ACTIVE_DAYS = 12; + +/** One observation, dated. */ +export interface DatedNotable extends DayNotable { + date: string; +} + +function tidy(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Extremes in one type's daily series, for the days from `from` on. The + * series is ascending by day and may start well before `from`; that part is + * history only. Linear: a stack of the earlier days not yet outdone gives + * each day the last earlier day at least as high (or as low). + */ +export function extremesInSeries( + type: MeasurementType, + series: ReadonlyArray, + from: string, +): DatedNotable[] { + const out: DatedNotable[] = []; + if (series.length === 0) return out; + const first = series[0][0]; + const highs: number[] = []; + const lows: number[] = []; + series.forEach(([day, value], i) => { + while (highs.length > 0 && series[highs[highs.length - 1]][1] < value) { + highs.pop(); + } + while (lows.length > 0 && series[lows[lows.length - 1]][1] > value) { + lows.pop(); + } + if (day >= from && i >= EXTREME_MIN_HISTORY_DAYS) { + const high = + highs.length > 0 ? series[highs[highs.length - 1]][0] : first; + const low = lows.length > 0 ? series[lows[lows.length - 1]][0] : first; + if (daysBetweenDateKeys(high, day) >= EXTREME_MIN_SPAN_DAYS) { + out.push({ + date: day, + kind: "extremeHigh", + type, + params: { since: high, value: tidy(value) }, + }); + } else if (daysBetweenDateKeys(low, day) >= EXTREME_MIN_SPAN_DAYS) { + out.push({ + date: day, + kind: "extremeLow", + type, + params: { since: low, value: tidy(value) }, + }); + } + } + highs.push(i); + lows.push(i); + }); + return out; +} + +/** + * Gaps in one type over `[from, to]`, dated on the first missing day. A gap + * still open at `to` counts the days up to `to`. + */ +export function gapsInSeries( + type: MeasurementType, + days: ReadonlySet, + from: string, + to: string, +): DatedNotable[] { + const out: DatedNotable[] = []; + let runStart: string | null = null; + const close = (end: string) => { + if (runStart === null) return; + const length = daysBetweenDateKeys(runStart, end) + 1; + if (length >= GAP_MIN_DAYS && wasHabit(days, runStart)) { + out.push({ date: runStart, kind: "gap", type, params: { days: length } }); + } + runStart = null; + }; + for (let day = from; day <= to; day = shiftDateKey(day, 1)) { + if (days.has(day)) close(shiftDateKey(day, -1)); + else if (runStart === null) runStart = day; + } + close(to); + return out; +} + +function wasHabit(days: ReadonlySet, gapStart: string): boolean { + let active = 0; + for (let i = 1; i <= GAP_LEAD_DAYS; i += 1) { + if (days.has(shiftDateKey(gapStart, -i))) active += 1; + } + return active >= GAP_MIN_ACTIVE_DAYS; +} + +/** A type's daily series as ascending `[day, value]` pairs. */ +function seriesOf( + stats: DailyStats, + type: MeasurementType, +): Array<[string, number]> { + return [...(stats.get(type) ?? new Map()).entries()].sort( + (a, b) => a[0].localeCompare(b[0]), + ); +} + +/** + * The first live reading of each type, as an instant. One index probe per + * type on `measurements_live_covering_idx`. + */ +export async function readFirstReadings( + userId: string, + types: readonly MeasurementType[], +): Promise> { + const out = new Map(); + if (types.length === 0) return out; + const rows = await prisma.$queryRaw< + Array<{ type: MeasurementType; first: Date | null }> + >` + SELECT wanted."type"::text AS "type", + (SELECT m."measured_at" FROM "measurements" m + WHERE m."user_id" = ${userId} AND m."type" = wanted."type" + AND m."deleted_at" IS NULL + ORDER BY m."measured_at" ASC LIMIT 1) AS "first" + FROM unnest(${[...types]}::"measurement_type"[]) AS wanted("type") + `; + for (const row of rows) if (row.first) out.set(row.type, row.first); + return out; +} + +/** + * The observations for a window of local days `[from, to]`. + * + * `typeVisible` is the module mask; a switched-off type is never read. + * `floor` keeps the comparison inside a reader's lookback limit. + */ +export async function loadNotableRange(args: { + userId: string; + from: string; + to: string; + tz: string; + priorityJson: unknown; + typeVisible: (type: MeasurementType) => boolean; + /** Whether to report gaps (the visit preparation only). */ + gaps: boolean; + /** Narrow the extreme types further (the day passes the types it holds). */ + extremeTypes?: readonly MeasurementType[]; + /** Types to report first readings for (the extreme types by default). */ + firstValueTypes?: readonly MeasurementType[]; + floor?: Date | null; +}): Promise { + const wanted = args.extremeTypes + ? new Set(args.extremeTypes) + : null; + const types = NOTABLE_EXTREME_TYPES.filter( + (t) => args.typeVisible(t) && (wanted === null || wanted.has(t)), + ); + const firstTypes = (args.firstValueTypes ?? types).filter(args.typeVisible); + let historyFrom = startOfLocalDayKey( + shiftDateKey(args.from, -NOTABLE_LOOKBACK_DAYS), + args.tz, + ); + if (args.floor && args.floor > historyFrom) historyFrom = args.floor; + const [stats, firsts] = await Promise.all([ + readLocalDailyStats({ + userId: args.userId, + types, + from: historyFrom, + to: startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz), + tz: args.tz, + priorityJson: args.priorityJson, + }), + readFirstReadings(args.userId, firstTypes), + ]); + + const out: DatedNotable[] = []; + for (const type of types) { + const series = seriesOf(stats, type); + out.push( + ...extremesInSeries(type, series, args.from).filter( + (n) => n.date <= args.to, + ), + ); + if (args.gaps) { + out.push( + ...gapsInSeries( + type, + new Set(series.map(([day]) => day)), + args.from, + args.to, + ), + ); + } + } + for (const [type, first] of firsts) { + const day = userDayKey(first, args.tz); + if (day >= args.from && day <= args.to) { + out.push({ date: day, kind: "firstValue", type, params: { type } }); + } + } + return out.sort( + (a, b) => + a.date.localeCompare(b.date) || + (a.type ?? "").localeCompare(b.type ?? ""), + ); +} diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts new file mode 100644 index 000000000..b1baef3e5 --- /dev/null +++ b/src/lib/day/records.ts @@ -0,0 +1,1066 @@ +/** + * The record side of a day: what ran through it and what happened on it, + * section by section (v1.42, #613). + * + * Every read is bounded to the day and indexed on `(userId, time field)`. + * A day is stored three ways in this schema, and each reader below uses the + * one its table speaks: + * + * - **an instant** (`takenAt`, `occurredAt`, `onsetAt`, `startedAt`): the + * half-open local window `[dayStart, dayEnd)` in the record's zone, 23, + * 24 or 25 hours long; + * - **a date string with its own zone** (mood, illness day logs, cycle): + * equality on the string, because the row already decided which day it + * belongs to and a second derivation could disagree with it; a + * screener's `takenAt` is cut in its own `tz` the same way; + * - **a calendar date** (`@db.Date` medication and course bounds, a + * document's stated date stored at noon UTC): compared as a date key, + * never through a zone. + * + * Free text is decrypted here for the caller that resolved the record and + * nowhere else. A value that cannot be decrypted is `null` with a warning on + * the request's event, never the ciphertext and never a blank that looks + * like "no note". + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import type { + DayEvent, + DayRunningItem, + DaySectionKey, +} from "@/lib/day/contract"; +import { prisma } from "@/lib/db"; +import { listTargetsBySource } from "@/lib/links/link-service"; +import { getEvent } from "@/lib/logging/context"; +import { lastDayCovered } from "@/lib/life-events/dates"; +import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import { effectiveMoodTz } from "@/lib/mood/date-key"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** Everything a section reader needs about the day. */ +export interface DayFrame { + userId: string; + day: string; + tz: string; + dayStart: Date; + dayEnd: Date; + /** Whether document names may be shown beside a visit or a dose. */ + documentsReadable: boolean; +} + +export interface SectionPart { + running: DayRunningItem[]; + events: DayEvent[]; +} + +/** A window wide enough to hold the day in any zone a row may carry. */ +function zoneSafeWindow(frame: DayFrame): { gte: Date; lt: Date } { + return { + gte: new Date(frame.dayStart.getTime() - 14 * 3_600_000), + lt: new Date(frame.dayEnd.getTime() + 14 * 3_600_000), + }; +} + +/** Decrypt a free-text column for the response, or null with a warning. */ +export function openText( + buf: Uint8Array | null | undefined, + what: string, +): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decryptFromBytes(buf); + } catch (err) { + getEvent()?.addWarning( + `day ${what} decrypt failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +/** Day n of a period that began on `since`, counting `since` as day 1. */ +export function dayIndexOf(since: string, day: string): number | null { + const n = daysBetweenDateKeys(since, day) + 1; + return n >= 1 ? n : null; +} + +function running( + frame: DayFrame, + item: Omit, +): DayRunningItem { + return { + ...item, + dayIndex: dayIndexOf(item.since, frame.day), + // An end before the start (a hand-edited row) has no length to state. + dayCount: + item.until === null || item.until < item.since + ? null + : daysBetweenDateKeys(item.since, item.until) + 1, + }; +} + +function event( + item: Omit & + Partial>, +): DayEvent { + return { meta: null, note: null, docs: [], ...item }; +} + +/** A `@db.Date` column value as its calendar key. */ +function dateKeyOrNull(value: Date | null): string | null { + return value ? dateOnlyKey(value) : null; +} + +/* ─── medications ─────────────────────────────────────────────────────────── */ + +async function medicationsPart(frame: DayFrame): Promise { + const { userId, day } = frame; + const dayDate = dayKeyAsUtcMidnight(day); + const [meds, courses, pauses, intakes, doseChanges] = await Promise.all([ + prisma.medication.findMany({ + where: { + userId, + asNeeded: false, + oneShot: false, + // Without a start date, the day the medication was entered stands in. + OR: [ + { startsOn: { lte: dayDate } }, + { startsOn: null, createdAt: { lt: frame.dayEnd } }, + ], + AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }], + }, + select: { + id: true, + name: true, + dose: true, + active: true, + startsOn: true, + endsOn: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.medicationCourse.findMany({ + where: { + userId, + startsOn: { lte: dayDate }, + OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }], + }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationPauseEra.findMany({ + where: { + userId, + pausedAt: { lt: frame.dayEnd }, + OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }], + }, + select: { + id: true, + medicationId: true, + pausedAt: true, + resumedAt: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { gte: frame.dayStart, lt: frame.dayEnd }, + ...TRACKED_INTAKE_EVENT_WHERE, + }, + select: { + id: true, + medicationId: true, + takenAt: true, + doseTaken: true, + medication: { select: { name: true, dose: true } }, + }, + orderBy: { takenAt: "asc" }, + }), + prisma.medicationDoseChange.findMany({ + where: { + medication: { userId }, + effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + medicationId: true, + effectiveFrom: true, + doseValue: true, + doseUnit: true, + note: true, + noteEncrypted: true, + medication: { select: { name: true } }, + }, + }), + ]); + + const out: SectionPart = { running: [], events: [] }; + for (const med of meds) { + const since = + dateKeyOrNull(med.startsOn) ?? userDayKey(med.createdAt, frame.tz); + // An ended medication without an end date stopped when it was last + // changed; that is the only date the record holds for it. + const until = + dateKeyOrNull(med.endsOn) ?? + (med.active ? null : userDayKey(med.updatedAt, frame.tz)); + if (since > day || (until !== null && until < day)) continue; + const href = `/medications/${med.id}`; + out.running.push( + running(frame, { + kind: "medication", + section: "medications", + id: med.id, + title: med.name, + sub: med.dose, + since, + until, + href, + }), + ); + if (dateKeyOrNull(med.startsOn) === day) { + out.events.push( + event({ + at: null, + kind: "medicationStart", + section: "medications", + id: med.id, + title: med.name, + meta: med.dose, + href, + }), + ); + } + if (dateKeyOrNull(med.endsOn) === day) { + out.events.push( + event({ + at: null, + kind: "medicationEnd", + section: "medications", + id: med.id, + title: med.name, + meta: med.dose, + href, + }), + ); + } + } + for (const course of courses) { + const since = dateOnlyKey(course.startsOn); + const until = dateKeyOrNull(course.endsOn); + const href = `/medications/${course.medicationId}`; + out.running.push( + running(frame, { + kind: "medicationCourse", + section: "medications", + id: course.id, + title: course.medication.name, + sub: null, + since, + until, + href, + }), + ); + if (since === day) { + out.events.push( + event({ + at: null, + kind: "courseStart", + section: "medications", + id: course.id, + title: course.medication.name, + href, + }), + ); + } + if (until === day) { + out.events.push( + event({ + at: null, + kind: "courseEnd", + section: "medications", + id: course.id, + title: course.medication.name, + href, + }), + ); + } + } + for (const pause of pauses) { + const since = userDayKey(pause.pausedAt, frame.tz); + const until = pause.resumedAt + ? userDayKey(pause.resumedAt, frame.tz) + : null; + const href = `/medications/${pause.medicationId}`; + out.running.push( + running(frame, { + kind: "medicationPause", + section: "medications", + id: pause.id, + title: pause.medication.name, + sub: null, + since, + until, + href, + }), + ); + if (pause.pausedAt >= frame.dayStart) { + out.events.push( + event({ + at: pause.pausedAt.toISOString(), + kind: "pauseStart", + section: "medications", + id: pause.id, + title: pause.medication.name, + href, + }), + ); + } + if ( + pause.resumedAt && + pause.resumedAt >= frame.dayStart && + pause.resumedAt < frame.dayEnd + ) { + out.events.push( + event({ + at: pause.resumedAt.toISOString(), + kind: "pauseEnd", + section: "medications", + id: pause.id, + title: pause.medication.name, + href, + }), + ); + } + } + for (const intake of intakes) { + out.events.push( + event({ + at: intake.takenAt ? intake.takenAt.toISOString() : null, + kind: "intake", + section: "medications", + id: intake.id, + title: intake.medication.name, + meta: intake.doseTaken ?? intake.medication.dose, + href: `/medications/${intake.medicationId}/history`, + }), + ); + } + for (const change of doseChanges) { + let note: string | null = null; + if (change.noteEncrypted && change.noteEncrypted.byteLength > 0) { + note = openText(change.noteEncrypted, "dose change note"); + } else { + note = change.note; + } + out.events.push( + event({ + at: change.effectiveFrom.toISOString(), + kind: "doseChange", + section: "medications", + id: change.id, + title: change.medication.name, + meta: `${change.doseValue} ${change.doseUnit}`, + note, + href: `/medications/${change.medicationId}/history`, + }), + ); + } + return out; +} + +/* ─── illness and symptoms ────────────────────────────────────────────────── */ + +async function illnessPart(frame: DayFrame): Promise { + const { userId, day } = frame; + const [episodes, dayLogs] = await Promise.all([ + prisma.illnessEpisode.findMany({ + where: { + userId, + deletedAt: null, + onsetAt: { lt: frame.dayEnd }, + OR: [{ resolvedAt: null }, { resolvedAt: { gte: frame.dayStart } }], + }, + select: { + id: true, + label: true, + lifecycle: true, + onsetAt: true, + resolvedAt: true, + }, + orderBy: { onsetAt: "asc" }, + }), + prisma.illnessDayLog.findMany({ + where: { userId, date: day, deletedAt: null }, + select: { + id: true, + episodeId: true, + functionalImpact: true, + noteEncrypted: true, + createdAt: true, + episode: { select: { label: true } }, + }, + }), + ]); + const out: SectionPart = { running: [], events: [] }; + for (const episode of episodes) { + const href = `/illness/${episode.id}`; + out.running.push( + running(frame, { + kind: "illness", + section: "illness", + id: episode.id, + title: episode.label, + sub: null, + since: userDayKey(episode.onsetAt, frame.tz), + until: episode.resolvedAt + ? userDayKey(episode.resolvedAt, frame.tz) + : null, + href, + }), + ); + if (episode.onsetAt >= frame.dayStart) { + out.events.push( + event({ + at: episode.onsetAt.toISOString(), + kind: "illnessOnset", + section: "illness", + id: episode.id, + title: episode.label, + href, + }), + ); + } + if (episode.resolvedAt && episode.resolvedAt < frame.dayEnd) { + out.events.push( + event({ + at: episode.resolvedAt.toISOString(), + kind: "illnessResolved", + section: "illness", + id: episode.id, + title: episode.label, + href, + }), + ); + } + } + for (const log of dayLogs) { + out.events.push( + event({ + at: null, + kind: "illnessDayLog", + section: "illness", + id: log.id, + title: log.episode.label, + meta: + log.functionalImpact === null ? null : String(log.functionalImpact), + note: openText(log.noteEncrypted, "illness day log note"), + href: `/illness/${log.episodeId}`, + }), + ); + } + return out; +} + +async function symptomsPart(frame: DayFrame): Promise { + const rows = await prisma.symptomEvent.findMany({ + where: { + userId: frame.userId, + occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + occurredAt: true, + intensity: true, + noteEncrypted: true, + episodeId: true, + definition: { select: { labelEncrypted: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.occurredAt.toISOString(), + kind: "symptom", + section: "symptoms", + id: row.id, + title: openText(row.definition.labelEncrypted, "symptom label") ?? "", + meta: String(row.intensity), + note: openText(row.noteEncrypted, "symptom note"), + href: row.episodeId ? `/illness/${row.episodeId}` : "/illness", + }), + ), + }; +} + +/* ─── profile: allergies, visits, vaccinations, lifestyle ─────────────────── */ + +async function allergiesPart(frame: DayFrame): Promise { + const rows = await prisma.allergy.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + onsetAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { id: true, substance: true, onsetAt: true, severity: true }, + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.onsetAt ? row.onsetAt.toISOString() : null, + kind: "allergyOnset", + section: "allergies", + id: row.id, + title: row.substance, + meta: row.severity, + href: "/profile", + }), + ), + }; +} + +async function visitsPart(frame: DayFrame): Promise { + const rows = await prisma.encounter.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + status: { in: ["DONE", "PLANNED"] }, + occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + occurredAt: true, + kind: true, + status: true, + reasonEncrypted: true, + outcomeEncrypted: true, + practitioner: { select: { name: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + const docs = await documentsFor( + frame, + "encounter", + rows.map((r) => r.id), + ); + return { + running: [], + events: rows.map((row) => + event({ + at: row.occurredAt.toISOString(), + kind: row.kind === "PROCEDURE" ? "procedure" : "visit", + section: "visits", + id: row.id, + title: + openText(row.reasonEncrypted, "visit reason") ?? + row.practitioner?.name ?? + "", + meta: row.status === "PLANNED" ? "PLANNED" : row.kind, + note: openText(row.outcomeEncrypted, "visit outcome"), + docs: docs.get(row.id) ?? [], + href: `/checkups?visit=${row.id}`, + }), + ), + }; +} + +async function vaccinationsPart(frame: DayFrame): Promise { + const rows = await prisma.vaccinationRecord.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + occurredAt: true, + vaccineName: true, + antigenSlug: true, + doseNumber: true, + noteEncrypted: true, + customVaccine: { select: { name: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + const docs = await documentsFor( + frame, + "vaccination", + rows.map((r) => r.id), + ); + return { + running: [], + events: rows.map((row) => + event({ + at: row.occurredAt.toISOString(), + kind: "vaccination", + section: "vaccinations", + id: row.id, + title: + row.vaccineName ?? row.customVaccine?.name ?? row.antigenSlug ?? "", + meta: row.doseNumber === null ? null : String(row.doseNumber), + note: openText(row.noteEncrypted, "vaccination note"), + docs: docs.get(row.id) ?? [], + href: `/vaccinations?dose=${row.id}`, + }), + ), + }; +} + +async function lifestylePart(frame: DayFrame): Promise { + const rows = await prisma.healthProfileFactRevision.findMany({ + where: { + userId: frame.userId, + supersededByRevisionId: null, + validFrom: { lt: frame.dayEnd }, + OR: [{ validUntil: null }, { validUntil: { gt: frame.dayStart } }], + }, + select: { + id: true, + kind: true, + valueEncrypted: true, + validFrom: true, + validUntil: true, + }, + orderBy: { validFrom: "desc" }, + }); + // One revision per kind: the one in force on the day. + const seen = new Set(); + const out: SectionPart = { running: [], events: [] }; + for (const row of rows) { + if (seen.has(row.kind)) continue; + seen.add(row.kind); + out.running.push( + running(frame, { + kind: "lifestyle", + section: "lifestyle", + id: row.id, + // The fact's kind is the title and its value the second line, both + // closed codes the client words (`SHIFT_SCHEDULE` / `NIGHT`). + title: row.kind, + sub: decryptHealthProfileFactValue(row.kind, row.valueEncrypted).value, + since: userDayKey(row.validFrom, frame.tz), + until: row.validUntil ? userDayKey(row.validUntil, frame.tz) : null, + href: "/profile", + }), + ); + } + return out; +} + +/* ─── labs, check-ups, documents ──────────────────────────────────────────── */ + +async function labsPart(frame: DayFrame): Promise { + const rows = await prisma.labResult.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + takenAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + analyte: true, + value: true, + valueText: true, + unit: true, + takenAt: true, + biomarkerId: true, + noteEncrypted: true, + }, + orderBy: [{ takenAt: "asc" }, { analyte: "asc" }], + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.takenAt.toISOString(), + kind: "labResult", + section: "labs", + id: row.id, + title: row.analyte, + meta: + row.value !== null + ? `${row.value} ${row.unit}`.trim() + : (row.valueText ?? null), + note: openText(row.noteEncrypted, "lab note"), + href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs", + }), + ), + }; +} + +async function checkupsPart(frame: DayFrame): Promise { + const rows = await prisma.measurementReminderEvent.findMany({ + where: { + userId: frame.userId, + kind: "SATISFIED", + occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + reminder: { deletedAt: null, origin: "VORSORGE" }, + }, + select: { + id: true, + occurredAt: true, + reminder: { select: { id: true, label: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.occurredAt.toISOString(), + kind: "checkup", + section: "checkups", + id: row.id, + title: row.reminder.label, + href: "/checkups", + }), + ), + }; +} + +async function documentsPart(frame: DayFrame): Promise { + // A document's date is a calendar date stored at noon UTC (older rows at + // UTC midnight): both anchors fall inside this UTC calendar day, and the + // date key decides. The stated report date wins over the filing date. + const start = dayKeyAsUtcMidnight(frame.day); + const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1)); + const rows = await prisma.inboundDocument.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + OR: [ + { reportDate: { gte: start, lt: end } }, + { reportDate: null, documentDate: { gte: start, lt: end } }, + ], + }, + select: { + id: true, + kind: true, + title: true, + filename: true, + reportDate: true, + documentDate: true, + }, + }); + return { + running: [], + events: rows + .filter( + (row) => + dateKeyOrNull(row.reportDate ?? row.documentDate) === frame.day, + ) + .map((row) => + event({ + at: null, + kind: "document", + section: "documents", + id: row.id, + title: row.title ?? row.filename ?? "", + meta: row.kind, + href: `/documents?doc=${row.id}`, + }), + ), + }; +} + +/** Documents filed against visits or doses, when the grant reaches the vault. */ +async function documentsFor( + frame: DayFrame, + sourceKind: "encounter" | "vaccination", + sourceIds: string[], +): Promise> { + const out = new Map(); + // The filename of a scanned page is itself the sensitive part, so a grant + // that does not reach the vault gets no names here. + if (!frame.documentsReadable || sourceIds.length === 0) return out; + const map = await listTargetsBySource(prisma, { + userId: frame.userId, + sourceKind, + sourceIds, + targetKind: "document", + }); + for (const [id, targets] of map) { + out.set( + id, + targets.map((t) => ({ id: t.id, name: t.label })), + ); + } + return out; +} + +/* ─── mind: mood and screeners ────────────────────────────────────────────── */ + +async function moodPart(frame: DayFrame): Promise { + const rows = await prisma.moodEntry.findMany({ + where: { userId: frame.userId, date: frame.day, deletedAt: null }, + select: { + id: true, + mood: true, + score: true, + moodLoggedAt: true, + note: true, + noteEncrypted: true, + }, + orderBy: { moodLoggedAt: "asc" }, + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.moodLoggedAt.toISOString(), + kind: "mood", + section: "mood", + id: row.id, + title: row.mood, + meta: String(row.score), + note: + row.noteEncrypted && row.noteEncrypted.byteLength > 0 + ? openText(row.noteEncrypted, "mood note") + : row.note, + href: `/mood?from=${frame.day}&to=${frame.day}`, + }), + ), + }; +} + +async function assessmentsPart(frame: DayFrame): Promise { + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + takenAt: zoneSafeWindow(frame), + }, + select: { + id: true, + instrument: true, + totalScore: true, + severityBand: true, + takenAt: true, + tz: true, + }, + orderBy: { takenAt: "asc" }, + }); + return { + running: [], + events: rows + // A screener carries the zone it was taken in; its day is cut there. + .filter( + (row) => + userDayKey(row.takenAt, row.tz ? effectiveMoodTz(row) : frame.tz) === + frame.day, + ) + .map((row) => + event({ + at: row.takenAt.toISOString(), + kind: "assessment", + section: "assessments", + id: row.id, + title: row.instrument, + meta: `${row.totalScore} ${row.severityBand}`, + href: "/mental-wellbeing", + }), + ), + }; +} + +/* ─── workouts, cycle ─────────────────────────────────────────────────────── */ + +async function workoutsPart(frame: DayFrame): Promise { + const rows = await prisma.workout.findMany({ + where: { + userId: frame.userId, + startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { id: true, sportType: true, startedAt: true, durationSec: true }, + orderBy: { startedAt: "asc" }, + }); + return { + running: [], + events: rows.map((row) => + event({ + at: row.startedAt.toISOString(), + kind: "workout", + section: "workouts", + id: row.id, + title: row.sportType, + meta: `${Math.round(row.durationSec / 60)} min`, + href: `/insights/workouts/${row.id}`, + }), + ), + }; +} + +async function cyclePart(frame: DayFrame): Promise { + const [cycles, logs] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + isPredicted: false, + absorbedIntoId: null, + startDate: { lte: frame.day }, + OR: [{ endDate: null }, { endDate: { gte: frame.day } }], + }, + select: { id: true, startDate: true, endDate: true }, + orderBy: { startDate: "desc" }, + take: 1, + }), + prisma.cycleDayLog.findMany({ + where: { userId: frame.userId, date: frame.day, deletedAt: null }, + select: { id: true, flow: true }, + }), + ]); + return { + running: cycles.map((cycle) => + running(frame, { + kind: "cyclePhase", + section: "cycle", + id: cycle.id, + title: "cycle", + sub: null, + since: cycle.startDate, + until: cycle.endDate, + href: "/cycle", + }), + ), + events: logs.map((log) => + event({ + at: null, + kind: "cycleDayLog", + section: "cycle", + id: log.id, + title: "cycle", + meta: log.flow, + href: "/cycle", + }), + ), + }; +} + +/* ─── environment, life events ────────────────────────────────────────────── */ + +async function environmentPart(frame: DayFrame): Promise { + // The travel period only; the day's weather row is a value the client + // reads from `/api/environment`. No place name leaves here: the label is + // sealed with the coordinates, and a trip is "away" without it. + const trips = await prisma.environmentTravelLocation.findMany({ + where: { + userId: frame.userId, + startDate: { lte: frame.day }, + endDate: { gte: frame.day }, + }, + select: { id: true, startDate: true, endDate: true }, + }); + return { + running: trips.map((trip) => + running(frame, { + kind: "travel", + section: "environment", + id: trip.id, + title: "travel", + sub: null, + since: trip.startDate, + until: trip.endDate, + href: null, + }), + ), + events: [], + }; +} + +async function lifeEventsPart(frame: DayFrame): Promise { + // Day-precise events on this day, and periods (any precision) spanning it. + const rows = await prisma.lifeEvent.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + startDate: { lte: frame.day }, + OR: [ + { startDate: frame.day, precision: "DAY" }, + // A coarse end reaches to the end of its month or year, so the row is + // a candidate from the first of the day's year; the exact rule runs + // below. + { endDate: { gte: `${frame.day.slice(0, 4)}-01-01` } }, + ], + }, + select: { + id: true, + startDate: true, + endDate: true, + precision: true, + category: true, + titleEncrypted: true, + noteEncrypted: true, + }, + orderBy: { startDate: "asc" }, + }); + const out: SectionPart = { running: [], events: [] }; + for (const row of rows) { + const title = openText(row.titleEncrypted, "life event title") ?? ""; + const href = `/timeline?day=${frame.day}`; + const end = + row.endDate === null ? null : lastDayCovered(row.endDate, row.precision); + if (end !== null && end >= frame.day) { + out.running.push( + running(frame, { + kind: "lifeEvent", + section: "lifeEvents", + id: row.id, + title, + sub: row.category, + since: row.startDate, + until: end, + href, + }), + ); + } + if (row.precision === "DAY" && row.startDate === frame.day) { + out.events.push( + event({ + at: null, + kind: "lifeEvent", + section: "lifeEvents", + id: row.id, + title, + meta: row.category, + note: openText(row.noteEncrypted, "life event note"), + href, + }), + ); + } + } + return out; +} + +/** The record-side reader of each section; values and sleep are elsewhere. */ +export const RECORD_SECTION_READERS: Readonly< + Partial Promise>> +> = Object.freeze({ + medications: medicationsPart, + illness: illnessPart, + symptoms: symptomsPart, + allergies: allergiesPart, + labs: labsPart, + visits: visitsPart, + vaccinations: vaccinationsPart, + checkups: checkupsPart, + documents: documentsPart, + mood: moodPart, + assessments: assessmentsPart, + workouts: workoutsPart, + cycle: cyclePart, + environment: environmentPart, + lifestyle: lifestylePart, + lifeEvents: lifeEventsPart, +}); diff --git a/src/lib/day/sections.ts b/src/lib/day/sections.ts new file mode 100644 index 000000000..c1ac90bfd --- /dev/null +++ b/src/lib/day/sections.ts @@ -0,0 +1,130 @@ +/** + * Which sections of a day the caller may see, decided once per request + * (v1.42, #613). + * + * Two masks, and they stay two. The module mask is the record owner's choice + * about what the product shows ("I switched cycle tracking off"); the sharing + * mask is the owner's choice about what a delegate may read ("my sister sees + * my medications"). A section the module mask hides is left out of the answer + * without a word, as every other surface does. A section the sharing mask + * withholds is named once in `sections` with `not_shared`, so the view can + * say "some areas are not shared with you" instead of pretending the day was + * empty there. + * + * Every section maps to the sharing domain the routes that own its rows + * already declare (the sharing guard freezes those), so the day can never show + * a delegate more of a section than its own list would. The environment rows + * have no delegable route at all, so they map to no domain and are read for + * the owner only. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import { DAY_SECTION_KEYS, type DaySectionKey } from "@/lib/day/contract"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { surfaceModule } from "@/lib/modules/surface"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +/** + * The sharing domain each section belongs to, or `null` for rows no delegate + * reaches through any route (read for the owner only). + * + * Mirrors the declarations of the routes that serve the same rows: readings, + * sleep, workouts and check-up reminders are `measurements`; mood and the + * screeners are `mind`; allergies, visits, vaccinations, the anamnesis facts + * and life events are `profile`; symptoms ride `illness`. + */ +export const DAY_SECTION_SHARE_DOMAIN: Readonly< + Record +> = Object.freeze({ + values: "measurements", + sleep: "measurements", + mood: "mind", + assessments: "mind", + medications: "medications", + illness: "illness", + symptoms: "illness", + allergies: "profile", + labs: "labs", + visits: "profile", + vaccinations: "profile", + checkups: "measurements", + documents: "documents", + workouts: "measurements", + cycle: "cycle", + environment: null, + lifestyle: "profile", + lifeEvents: "profile", +}); + +/** The module that owns a section, from the one surface map. */ +export function daySectionModule(section: DaySectionKey): ModuleKey | null { + return surfaceModule(`day-section:${section}`) ?? null; +} + +/** What one request may read of a record's days. */ +export interface DayAccess { + /** Sections whose rows are read and returned. */ + readable: ReadonlySet; + /** Sections withheld by the grant, each named once. */ + notShared: readonly DaySectionKey[]; + /** The record's module switches, resolved once. */ + modules: Readonly>; + /** Sections hidden because their module is off (not sent on the web wire). */ + moduleOff: readonly DaySectionKey[]; +} + +/** + * Resolve the readable sections from the module switches and the grant. + * + * `domainVisible` is the predicate `actingDomainVisibility` returns: always + * true on the owner's own record, the grant's sections otherwise. `owner` is + * true only when the caller reads their own record (no grant); it opens the + * sections that map to no sharing domain. + */ +export function resolveDayAccessFrom(args: { + modules: Readonly>; + domainVisible: (domain: ShareDomain) => boolean; + owner: boolean; +}): DayAccess { + const readable = new Set(); + const notShared: DaySectionKey[] = []; + const moduleOff: DaySectionKey[] = []; + for (const section of DAY_SECTION_KEYS) { + const owner = daySectionModule(section); + if (owner !== null && args.modules[owner] === false) { + moduleOff.push(section); + continue; + } + const domain = DAY_SECTION_SHARE_DOMAIN[section]; + const shared = domain === null ? args.owner : args.domainVisible(domain); + if (shared) readable.add(section); + else notShared.push(section); + } + return { readable, notShared, modules: args.modules, moduleOff }; +} + +/** {@link resolveDayAccessFrom} with the module map read for the record. */ +export async function resolveDayAccess(args: { + recordId: string; + domainVisible: (domain: ShareDomain) => boolean; + owner: boolean; +}): Promise { + const modules = await resolveModuleMap(args.recordId); + return resolveDayAccessFrom({ ...args, modules }); +} + +/** + * Whether a reading of `type` may appear: its own module (from the one + * ownership table) has to be on. The `values` and `sleep` sections are + * checked by the caller; this narrows them type by type, so a switched-off + * `recovery` module takes HRV out of the day while the core vitals stay. + */ +export function measurementTypeVisible( + type: MeasurementType, + modules: Readonly>, +): boolean { + const owner = moduleForMeasurementType(type); + return owner === null || modules[owner] !== false; +} diff --git a/src/lib/day/values.ts b/src/lib/day/values.ts new file mode 100644 index 000000000..5d05b2554 --- /dev/null +++ b/src/lib/day/values.ts @@ -0,0 +1,174 @@ +/** + * The readings of one local day and the person's usual range beside each + * (v1.42, #613). + * + * One entry per reading for the types a person takes by hand or a device + * takes a few times a day (weight, blood pressure, glucose). A type that + * arrives as a day total is one entry with the total; a type sampled all day + * long (pulse, gait figures, a watch's background readings) is one entry with + * the day value instead of hundreds of points. "All day long" is a fixed list + * plus a count: more than {@link MAX_READINGS_PER_TYPE} canonical readings of + * one type collapse to the day value too, so a CGM day does not send 288 rows. + * The collapsed entry carries the time of the last reading it folds. + * + * The range is the record's own: median ± a scaled MAD over the daily values + * of the 30 local days before, the same band the vitals baseline draws, with + * at least {@link MIN_BAND_DAYS} days behind it. It describes, it does not + * grade; nothing here says "high". + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import type { DayValue } from "@/lib/day/contract"; +import { readLocalDailyStats } from "@/lib/day/daily-stats"; +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; +import { HIGH_FREQUENCY_MEAN_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; +import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic"; +import type { DayReadingRow, DayReadings } from "@/lib/mood/linked-context"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey } from "@/lib/tz/format"; + +/** More canonical readings of one type than this fold to the day value. */ +export const MAX_READINGS_PER_TYPE = 24; + +/** Days of history the usual range needs before it is drawn. */ +export const MIN_BAND_DAYS = 7; + +/** Local days the usual range looks back over. */ +export const BAND_WINDOW_DAYS = 30; + +/** How a type's readings of one day become entries. */ +export type DayValueShape = "readings" | "total" | "dayValue"; + +export function dayValueShape( + type: MeasurementType, + readingCount: number, +): DayValueShape { + if (isCumulativeDaySumType(type)) return "total"; + if (HOURLY_MEAN_DAY_TYPES.has(type) || HIGH_FREQUENCY_MEAN_TYPES.has(type)) { + return "dayValue"; + } + return readingCount > MAX_READINGS_PER_TYPE ? "dayValue" : "readings"; +} + +/** The day value of a type's readings, as `daily-stats.ts` computes it. */ +export function dayValueOf( + type: MeasurementType, + rows: readonly DayReadingRow[], +): number { + if (isCumulativeDaySumType(type)) { + return rows.reduce((sum, r) => sum + r.value, 0); + } + if (HOURLY_MEAN_DAY_TYPES.has(type)) { + const hours = new Map(); + for (const r of rows) { + const hour = Math.floor(r.measuredAt.getTime() / 3_600_000); + const cell = hours.get(hour) ?? { total: 0, count: 0 }; + cell.total += r.value; + cell.count += 1; + hours.set(hour, cell); + } + let sum = 0; + for (const cell of hours.values()) sum += cell.total / cell.count; + return sum / hours.size; + } + return rows.reduce((sum, r) => sum + r.value, 0) / rows.length; +} + +/** Round to a precision that keeps a reading readable and a mean honest. */ +function tidy(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * The entries for one day, with the band from `bands` where one exists. + * Pure; `buildDayValues` below does the reads. + */ +export function shapeDayValues( + readings: DayReadings, + bands: ReadonlyMap, + includeSleep: boolean, +): DayValue[] { + const out: DayValue[] = []; + for (const [type, rows] of readings.rowsByType) { + if (rows.length === 0) continue; + const band = bands.get(type) ?? null; + const shape = dayValueShape(type, rows.length); + const last = rows[rows.length - 1]; + if (shape === "readings") { + for (const row of rows) { + out.push({ + type, + value: row.value, + unit: row.unit, + at: row.measuredAt.toISOString(), + source: row.source, + band, + }); + } + } else { + out.push({ + type, + value: tidy(dayValueOf(type, rows)), + unit: last.unit, + at: last.measuredAt.toISOString(), + source: last.source, + band, + }); + } + } + if (includeSleep && readings.night) { + out.push({ + type: "SLEEP_DURATION", + value: readings.night.asleepMinutes, + unit: "min", + at: readings.night.measuredAt.toISOString(), + source: readings.nightSource ?? "MANUAL", + band: null, + }); + } + return out.sort((a, b) => a.at.localeCompare(b.at)); +} + +/** + * The usual range of each type over the {@link BAND_WINDOW_DAYS} local days + * before `day`. `floor` cuts the window for a reader that may not look back + * further (the Coach's lookback limit). + */ +export async function readDayBands(args: { + userId: string; + day: string; + tz: string; + types: readonly MeasurementType[]; + priorityJson: unknown; + floor: Date | null; +}): Promise> { + const out = new Map(); + if (args.types.length === 0) return out; + const to = startOfLocalDayKey(args.day, args.tz); + let from = startOfLocalDayKey( + shiftDateKey(args.day, -BAND_WINDOW_DAYS), + args.tz, + ); + if (args.floor && args.floor > from) from = args.floor; + const stats = await readLocalDailyStats({ + userId: args.userId, + types: args.types, + from, + to, + tz: args.tz, + priorityJson: args.priorityJson, + }); + for (const type of args.types) { + const days = stats.get(type); + if (!days || days.size < MIN_BAND_DAYS) continue; + const band = buildBaselineBand([...days.values()], type); + if (!band) continue; + out.set(type, { + lo: tidy(band.low), + hi: tidy(band.high), + n: band.sampleDays, + }); + } + return out; +} diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts new file mode 100644 index 000000000..b4f6f67e2 --- /dev/null +++ b/src/lib/day/visit-preparation.ts @@ -0,0 +1,411 @@ +/** + * "Since the last visit": the window a person brings to the next appointment + * (v1.42, #613). + * + * The observations of `notable.ts` over the window (extremes, first readings + * and, here only, gaps) plus the context changes that happened in it: dose + * changes, medications and courses starting or ending, pauses, illnesses + * starting or ending, vaccinations, procedures and the days with new lab + * results. A list, oldest first, no ranking and no reading of causes. The + * view shows the first few and puts the rest behind "All". + * + * Each change belongs to a day section and appears only when the caller may + * read that section; observations need the readings. + */ +import type { + DayChange, + DayNotableWindowResponse, + DaySectionKey, +} from "@/lib/day/contract"; +import { DAY_NOTABLE_MAX_SPAN_DAYS } from "@/lib/day/contract"; +import { loadNotableRange } from "@/lib/day/notable"; +import { openText } from "@/lib/day/records"; +import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; +import { prisma } from "@/lib/db"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +/** The window when the record holds no visit the caller may see. */ +export const FALLBACK_WINDOW_DAYS = 90; + +/** At most this many lab analytes are named on one lab day. */ +const LAB_NAMES_PER_DAY = 5; + +interface Window { + userId: string; + from: string; + to: string; + tz: string; + start: Date; + end: Date; +} + +function change( + date: string, + section: DaySectionKey, + rest: Omit & { count?: number }, +): DayChange { + return { date, section, count: 1, ...rest }; +} + +async function medicationChanges(w: Window): Promise { + const startKey = dayKeyAsUtcMidnight(w.from); + const endKey = dayKeyAsUtcMidnight(w.to); + const inWindow = { gte: w.start, lt: w.end }; + const [doseChanges, meds, courses, pauses] = await Promise.all([ + prisma.medicationDoseChange.findMany({ + where: { medication: { userId: w.userId }, effectiveFrom: inWindow }, + select: { + id: true, + medicationId: true, + effectiveFrom: true, + medication: { select: { name: true } }, + }, + }), + prisma.medication.findMany({ + where: { + userId: w.userId, + OR: [ + { startsOn: { gte: startKey, lte: endKey } }, + { endsOn: { gte: startKey, lte: endKey } }, + ], + }, + select: { id: true, name: true, startsOn: true, endsOn: true }, + }), + prisma.medicationCourse.findMany({ + where: { + userId: w.userId, + OR: [ + { startsOn: { gte: startKey, lte: endKey } }, + { endsOn: { gte: startKey, lte: endKey } }, + ], + }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationPauseEra.findMany({ + where: { + userId: w.userId, + OR: [{ pausedAt: inWindow }, { resumedAt: inWindow }], + }, + select: { + id: true, + medicationId: true, + pausedAt: true, + resumedAt: true, + medication: { select: { name: true } }, + }, + }), + ]); + const out: DayChange[] = []; + const within = (day: string) => day >= w.from && day <= w.to; + for (const row of doseChanges) { + out.push( + change(userDayKey(row.effectiveFrom, w.tz), "medications", { + kind: "doseChange", + id: row.id, + title: row.medication.name, + href: `/medications/${row.medicationId}/history`, + }), + ); + } + for (const med of meds) { + const href = `/medications/${med.id}`; + const start = med.startsOn ? dateOnlyKey(med.startsOn) : null; + const end = med.endsOn ? dateOnlyKey(med.endsOn) : null; + if (start && within(start)) { + out.push( + change(start, "medications", { + kind: "medicationStart", + id: med.id, + title: med.name, + href, + }), + ); + } + if (end && within(end)) { + out.push( + change(end, "medications", { + kind: "medicationEnd", + id: med.id, + title: med.name, + href, + }), + ); + } + } + for (const course of courses) { + const href = `/medications/${course.medicationId}`; + const start = dateOnlyKey(course.startsOn); + const end = course.endsOn ? dateOnlyKey(course.endsOn) : null; + if (within(start)) { + out.push( + change(start, "medications", { + kind: "courseStart", + id: course.id, + title: course.medication.name, + href, + }), + ); + } + if (end && within(end)) { + out.push( + change(end, "medications", { + kind: "courseEnd", + id: course.id, + title: course.medication.name, + href, + }), + ); + } + } + for (const pause of pauses) { + const href = `/medications/${pause.medicationId}`; + const start = userDayKey(pause.pausedAt, w.tz); + if (within(start)) { + out.push( + change(start, "medications", { + kind: "pauseStart", + id: pause.id, + title: pause.medication.name, + href, + }), + ); + } + if (pause.resumedAt) { + const end = userDayKey(pause.resumedAt, w.tz); + if (within(end)) { + out.push( + change(end, "medications", { + kind: "pauseEnd", + id: pause.id, + title: pause.medication.name, + href, + }), + ); + } + } + } + return out; +} + +async function illnessChanges(w: Window): Promise { + const inWindow = { gte: w.start, lt: w.end }; + const rows = await prisma.illnessEpisode.findMany({ + where: { + userId: w.userId, + deletedAt: null, + OR: [{ onsetAt: inWindow }, { resolvedAt: inWindow }], + }, + select: { id: true, label: true, onsetAt: true, resolvedAt: true }, + }); + const out: DayChange[] = []; + for (const row of rows) { + const href = `/illness/${row.id}`; + if (row.onsetAt >= w.start && row.onsetAt < w.end) { + out.push( + change(userDayKey(row.onsetAt, w.tz), "illness", { + kind: "illnessOnset", + id: row.id, + title: row.label, + href, + }), + ); + } + if (row.resolvedAt && row.resolvedAt >= w.start && row.resolvedAt < w.end) { + out.push( + change(userDayKey(row.resolvedAt, w.tz), "illness", { + kind: "illnessResolved", + id: row.id, + title: row.label, + href, + }), + ); + } + } + return out; +} + +async function vaccinationChanges(w: Window): Promise { + const rows = await prisma.vaccinationRecord.findMany({ + where: { + userId: w.userId, + deletedAt: null, + occurredAt: { gte: w.start, lt: w.end }, + }, + select: { + id: true, + occurredAt: true, + vaccineName: true, + antigenSlug: true, + customVaccine: { select: { name: true } }, + }, + }); + return rows.map((row) => + change(userDayKey(row.occurredAt, w.tz), "vaccinations", { + kind: "vaccination", + id: row.id, + title: + row.vaccineName ?? row.customVaccine?.name ?? row.antigenSlug ?? "", + href: `/vaccinations?dose=${row.id}`, + }), + ); +} + +async function procedureChanges(w: Window): Promise { + const rows = await prisma.encounter.findMany({ + where: { + userId: w.userId, + deletedAt: null, + status: "DONE", + kind: "PROCEDURE", + occurredAt: { gte: w.start, lt: w.end }, + }, + select: { id: true, occurredAt: true, reasonEncrypted: true }, + }); + return rows.map((row) => + change(userDayKey(row.occurredAt, w.tz), "visits", { + kind: "procedure", + id: row.id, + title: openText(row.reasonEncrypted, "procedure reason") ?? "", + href: `/checkups?visit=${row.id}`, + }), + ); +} + +async function labChanges(w: Window): Promise { + const rows = await prisma.labResult.findMany({ + where: { + userId: w.userId, + deletedAt: null, + takenAt: { gte: w.start, lt: w.end }, + }, + select: { id: true, analyte: true, takenAt: true }, + orderBy: [{ takenAt: "asc" }, { analyte: "asc" }], + }); + const byDay = new Map(); + for (const row of rows) { + const day = userDayKey(row.takenAt, w.tz); + const slot = byDay.get(day) ?? { ids: [], names: [] }; + slot.ids.push(row.id); + if (!slot.names.includes(row.analyte)) slot.names.push(row.analyte); + byDay.set(day, slot); + } + return [...byDay].map(([day, slot]) => + change(day, "labs", { + kind: "labResult", + id: slot.ids[0], + title: slot.names.slice(0, LAB_NAMES_PER_DAY).join(", "), + count: slot.ids.length, + href: "/labs", + }), + ); +} + +const CHANGE_READERS: ReadonlyArray< + [DaySectionKey, (w: Window) => Promise] +> = [ + ["medications", medicationChanges], + ["illness", illnessChanges], + ["vaccinations", vaccinationChanges], + ["visits", procedureChanges], + ["labs", labChanges], +]; + +/** The local date of the last completed visit before `today`, if any. */ +async function lastVisitDay( + userId: string, + tz: string, + before: Date, +): Promise { + const row = await prisma.encounter.findFirst({ + where: { + userId, + deletedAt: null, + status: "DONE", + occurredAt: { lt: before }, + }, + select: { occurredAt: true }, + orderBy: { occurredAt: "desc" }, + }); + return row ? userDayKey(row.occurredAt, tz) : null; +} + +export async function loadSinceLastVisit(args: { + recordId: string; + access: DayAccess; + tz: string; + today: string; + from?: string; + to?: string; +}): Promise { + const { recordId, access, tz } = args; + const to = args.to ?? args.today; + let from: string; + let anchor: DayNotableWindowResponse["anchor"]; + if (args.from) { + from = args.from; + anchor = "requested"; + } else { + const last = access.readable.has("visits") + ? await lastVisitDay(recordId, tz, startOfLocalDayKey(to, tz)) + : null; + from = last ?? shiftDateKey(to, -FALLBACK_WINDOW_DAYS); + anchor = last ? "lastVisit" : "fallback"; + } + if (daysBetweenDateKeys(from, to) >= DAY_NOTABLE_MAX_SPAN_DAYS) { + from = shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)); + } + + const w: Window = { + userId: recordId, + from, + to, + tz, + start: startOfLocalDayKey(from, tz), + end: startOfLocalDayKey(shiftDateKey(to, 1), tz), + }; + const valuesReadable = access.readable.has("values"); + const [observations, ...changeLists] = await Promise.all([ + valuesReadable + ? loadUserSourcePriority(recordId).then((priorityJson) => + loadNotableRange({ + userId: recordId, + from, + to, + tz, + priorityJson, + typeVisible: (type) => measurementTypeVisible(type, access.modules), + gaps: true, + }), + ) + : Promise.resolve([]), + ...CHANGE_READERS.filter(([section]) => access.readable.has(section)).map( + ([, read]) => read(w), + ), + ]); + + return { + from, + to, + anchor, + observations: observations.map(({ date, kind, type, params }) => ({ + date, + kind, + type, + params, + })), + changes: changeLists + .flat() + .sort( + (a, b) => a.date.localeCompare(b.date) || a.kind.localeCompare(b.kind), + ), + }; +} diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 62a004820..1a535c4b1 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -198,6 +198,65 @@ export const dayIndexResponseSchema = z }) .meta({ id: "DayIndex" }); +/* ─── GET /api/day/notable ────────────────────────────────────────────────── */ + +/** + * The window before the next visit: "what happened since the last one". Both + * ends are optional; the server fills `from` with the last completed visit + * and `to` with today, and says which it used. + */ +export const dayNotableQuerySchema = z + .object({ from: dateKeySchema.optional(), to: dateKeySchema.optional() }) + .refine((q) => q.from === undefined || q.to === undefined || q.from <= q.to, { + message: "`from` must not be after `to`", + path: ["to"], + }); + +const dayChange = z + .object({ + date: dateKeySchema, + kind: z.enum(DAY_EVENT_KINDS), + section: daySectionKey, + id: z.string(), + title: z + .string() + .describe("The record's own name for it, decrypted for the caller."), + count: z + .number() + .int() + .positive() + .describe("Entries folded into this one (lab results of one day)."), + href: z.string().nullable(), + }) + .meta({ id: "DayChange" }); + +export const dayNotableResponseSchema = z + .object({ + from: dateKeySchema, + to: dateKeySchema, + anchor: z + .enum(["lastVisit", "requested", "fallback"]) + .describe( + "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record holds no visit the caller may see.", + ), + observations: z.array( + z + .object({ + date: dateKeySchema, + kind: z.enum(DAY_NOTABLE_KINDS), + type: z.string().nullable(), + params: messageParams, + }) + .meta({ id: "DatedDayNotable" }), + ), + changes: z + .array(dayChange) + .describe( + "Context changes in the window, oldest first: dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures and lab days.", + ), + }) + .meta({ id: "DayNotableWindow" }); + /* ─── GET /api/timeline ───────────────────────────────────────────────────── */ export const timelineQuerySchema = z.object({ diff --git a/src/lib/http/not-implemented.ts b/src/lib/http/not-implemented.ts deleted file mode 100644 index 1e4b0bac7..000000000 --- a/src/lib/http/not-implemented.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The answer a route gives while its contract exists and its body does not. - * - * A release built in parallel packages lands every new route as a stub first, - * registered in the OpenAPI table with its final shape, so the route-coverage - * and contract checks hold in every package's worktree from the start. The - * stub authenticates exactly as the finished route will and then answers 501. - * - * Nothing may ship with a stub in it: the error-code catalogue check fails on - * a listed code no route emits, so the code leaves the catalogue, and this - * file with it, once the last stub is implemented. - */ -import { apiError } from "@/lib/api-response"; - -export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented"; - -export function notImplemented() { - return apiError("Not implemented", 501, { - errorCode: NOT_IMPLEMENTED_ERROR_CODE, - }); -} diff --git a/src/lib/life-events/__tests__/service.test.ts b/src/lib/life-events/__tests__/service.test.ts new file mode 100644 index 000000000..583d5c233 --- /dev/null +++ b/src/lib/life-events/__tests__/service.test.ts @@ -0,0 +1,79 @@ +/** + * Editing a life event (v1.42, #613): the merged event is checked against + * the create rules, and only what the edit carries is written. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new TextEncoder().encode(`enc:${s}`), + decryptFromBytes: (b: Uint8Array) => + new TextDecoder().decode(b).replace(/^enc:/, ""), +})); + +import type { LifeEvent } from "@/generated/prisma/client"; + +import { mergeLifeEventEdit, toLifeEventDTO } from "../service"; + +const stored: LifeEvent = { + id: "le1", + userId: "u1", + category: "HOME", + startDate: "2023-09-01", + endDate: null, + precision: "MONTH", + titleEncrypted: new TextEncoder().encode("enc:Moved") as never, + noteEncrypted: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + deletedAt: null, +}; + +describe("life event edit", () => { + it("refuses a precision change that misaligns the stored date", () => { + const merged = mergeLifeEventEdit(stored, { precision: "YEAR" }); + expect(merged.ok).toBe(false); + }); + + it("accepts the precision with a matching date and writes only those keys", () => { + const merged = mergeLifeEventEdit(stored, { + precision: "YEAR", + startDate: "2023-01-01", + }); + expect(merged.ok).toBe(true); + if (merged.ok) { + expect(Object.keys(merged.data).sort()).toEqual([ + "precision", + "startDate", + ]); + } + }); + + it("refuses an end before the stored start", () => { + expect(mergeLifeEventEdit(stored, { endDate: "2023-08-01" }).ok).toBe( + false, + ); + }); + + it("encrypts a new title and clears a note", () => { + const merged = mergeLifeEventEdit(stored, { + title: "Moved out", + note: null, + }); + expect(merged.ok).toBe(true); + if (merged.ok) { + expect(merged.data.noteEncrypted).toBeNull(); + expect( + new TextDecoder().decode(merged.data.titleEncrypted as Uint8Array), + ).toBe("enc:Moved out"); + } + }); + + it("decrypts for the response", () => { + expect(toLifeEventDTO(stored)).toMatchObject({ + title: "Moved", + note: null, + startDate: "2023-09-01", + }); + }); +}); diff --git a/src/lib/life-events/dates.ts b/src/lib/life-events/dates.ts new file mode 100644 index 000000000..9c54abf9b --- /dev/null +++ b/src/lib/life-events/dates.ts @@ -0,0 +1,21 @@ +/** + * Calendar arithmetic for life-event dates (v1.42, #613). Pure, no zone: a + * life-event date is a calendar date aligned to its precision. + */ +import { shiftDateKey } from "@/lib/tz/format"; + +/** + * The last calendar day a life-event date covers at its precision: the day + * itself, the last day of its month, or the last day of its year. + */ +export function lastDayCovered( + date: string, + precision: "DAY" | "MONTH" | "YEAR", +): string { + if (precision === "DAY") return date; + if (precision === "YEAR") return `${date.slice(0, 4)}-12-31`; + const [y, m] = date.split("-").map(Number); + const next = + m === 12 ? `${y + 1}-01-01` : `${y}-${String(m + 1).padStart(2, "0")}-01`; + return shiftDateKey(next, -1); +} diff --git a/src/lib/life-events/service.ts b/src/lib/life-events/service.ts new file mode 100644 index 000000000..a0a1e6e40 --- /dev/null +++ b/src/lib/life-events/service.ts @@ -0,0 +1,147 @@ +/** + * Life events: the record's own dated anchors (v1.42, #613). + * + * A move, a birth, a new job, a loss. Title and note are encrypted at rest + * (`titleEncrypted`, `noteEncrypted`, registered for key rotation) and are + * decrypted only for the session that resolved the record. They are part of + * the `profile` sharing domain and never reach a model: the day's model + * projection drops them and no Coach or MCP read touches this table. + * + * Dates are calendar dates without a zone, aligned to their precision: at + * MONTH a date is the first of its month, at YEAR the first of its year + * (`lifeEventCreateSchema`). An edit is merged onto the stored row and the + * merged event is checked against the create rules again, because changing + * the precision alone can misalign a date the edit does not carry. + */ +import type { z } from "zod/v4"; + +import type { LifeEvent, Prisma } from "@/generated/prisma/client"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import type { + LifeEventCreateInput, + LifeEventDTO, + LifeEventUpdateInput, +} from "@/lib/day/contract"; +import { lifeEventCreateSchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +type LifeEventRow = Pick< + LifeEvent, + | "id" + | "category" + | "startDate" + | "endDate" + | "precision" + | "titleEncrypted" + | "noteEncrypted" + | "createdAt" + | "updatedAt" +>; + +function open(buf: Uint8Array | null, field: "title" | "note"): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decryptFromBytes(buf); + } catch (err) { + getEvent()?.addWarning( + `life event ${field} decrypt failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +export function toLifeEventDTO(row: LifeEventRow): LifeEventDTO { + return { + id: row.id, + category: row.category, + startDate: row.startDate, + endDate: row.endDate, + precision: row.precision, + title: open(row.titleEncrypted, "title"), + note: open(row.noteEncrypted, "note"), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** The record's live life events, oldest first. */ +export async function listLifeEvents(userId: string): Promise { + const rows = await prisma.lifeEvent.findMany({ + where: { userId, deletedAt: null }, + orderBy: [{ startDate: "asc" }, { createdAt: "asc" }], + }); + return rows.map(toLifeEventDTO); +} + +/** Field by field, never a spread of the parsed body. */ +export async function createLifeEvent( + userId: string, + input: LifeEventCreateInput, +): Promise { + const note = input.note ?? null; + return prisma.lifeEvent.create({ + data: { + userId, + category: input.category, + startDate: input.startDate, + endDate: input.endDate ?? null, + precision: input.precision, + titleEncrypted: encryptToBytes(input.title), + noteEncrypted: note ? encryptToBytes(note) : null, + }, + }); +} + +/** A live life event of this record, or null for a foreign or deleted id. */ +export async function findOwnLifeEvent( + userId: string, + id: string, +): Promise { + const row = await prisma.lifeEvent.findUnique({ where: { id } }); + if (!row || row.userId !== userId || row.deletedAt !== null) return null; + return row; +} + +export type LifeEventMergeResult = + | { ok: true; data: Prisma.LifeEventUpdateInput } + | { ok: false; error: z.ZodError }; + +/** + * Merge an edit onto the stored row and check the result against the create + * rules. Only the keys the edit carries are written; the stored title is + * checked through its plaintext, which the merge needs to validate anyway. + */ +export function mergeLifeEventEdit( + existing: LifeEvent, + edit: LifeEventUpdateInput, +): LifeEventMergeResult { + const merged = { + category: edit.category ?? existing.category, + startDate: edit.startDate ?? existing.startDate, + endDate: edit.endDate === undefined ? existing.endDate : edit.endDate, + precision: edit.precision ?? existing.precision, + title: edit.title ?? open(existing.titleEncrypted, "title") ?? "", + note: + edit.note === undefined + ? open(existing.noteEncrypted, "note") + : edit.note, + }; + const checked = lifeEventCreateSchema.safeParse(merged); + if (!checked.success) return { ok: false, error: checked.error }; + + const data: Prisma.LifeEventUpdateInput = {}; + if (edit.category !== undefined) data.category = edit.category; + if (edit.startDate !== undefined) data.startDate = edit.startDate; + if (edit.endDate !== undefined) data.endDate = edit.endDate; + if (edit.precision !== undefined) data.precision = edit.precision; + if (edit.title !== undefined) { + data.titleEncrypted = encryptToBytes(checked.data.title); + } + if (edit.note !== undefined) { + const note = checked.data.note ?? null; + data.noteEncrypted = note ? encryptToBytes(note) : null; + } + return { ok: true, data }; +} diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index e1551e7d7..b7cd38156 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -209,6 +209,8 @@ describe("MCP tool registry — surface", () => { "get_ecg_recordings", // v1.42 — weather, air quality and pollen of the stored days. "get_environment", + // v1.42 — one day across the record, never life events or notes. + "get_day", // v1.38 — the bounded visit history. "get_visits", ].sort(), diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 3696449a8..ca57d2f1e 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -21,6 +21,19 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions"; +import { readDayForTool } from "@/lib/ai/coach/tools/day-read"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { + DAY_EVENT_KINDS, + DAY_NOTABLE_KINDS, + DAY_QUERY_PARAM, + DAY_RUNNING_KINDS, + DAY_SECTION_KEYS, + DAY_TOOL_NAME, + MODEL_EXCLUDED_DAY_SECTIONS, + type DaySectionKey, +} from "@/lib/day/contract"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { @@ -962,6 +975,74 @@ const getEnvironmentOutput: z.ZodRawShape = { .optional(), }; +/** + * v1.42 (#613) — `get_day`. Declared field by field, so the advertised schema + * itself says what a day can carry for a model: no note, no document name, + * and no life-event section anywhere (the section enum below leaves it out, + * and a strict client refuses a result that grew one). Titles are the + * person's own words and come fenced. + */ +const MODEL_DAY_SECTIONS = DAY_SECTION_KEYS.filter( + (key) => !(MODEL_EXCLUDED_DAY_SECTIONS as readonly string[]).includes(key), +) as [DaySectionKey, ...DaySectionKey[]]; +const modelDaySection = z.enum(MODEL_DAY_SECTIONS); +const getDayOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + url: z.string().optional(), + data: z + .object({ + date: z.string(), + counts: z.object({ values: z.number(), entries: z.number() }), + running: z.array( + z.object({ + kind: z.enum(DAY_RUNNING_KINDS), + section: modelDaySection, + title: z.string(), + sub: z.string().nullable(), + since: z.string(), + until: z.string().nullable(), + dayIndex: z.number().nullable(), + dayCount: z.number().nullable(), + }), + ), + values: z.array( + z.object({ + type: z.string(), + value: z.number(), + unit: z.string(), + at: z.string(), + band: z + .object({ lo: z.number(), hi: z.number(), n: z.number() }) + .nullable(), + }), + ), + events: z.array( + z.object({ + at: z.string().nullable(), + kind: z.enum(DAY_EVENT_KINDS), + section: modelDaySection, + title: z.string(), + meta: z.string().nullable(), + }), + ), + notable: z.array( + z.object({ + kind: z.enum(DAY_NOTABLE_KINDS), + type: z.string().nullable(), + params: z.record(z.string(), z.union([z.string(), z.number()])), + }), + ), + unavailable: z.array( + z.object({ + section: modelDaySection, + reason: z.enum(["module_disabled", "not_shared"]), + }), + ), + }) + .optional(), +}; + const getCorrelationOutput: z.ZodRawShape = { present: z.boolean(), reason: z.string().optional(), @@ -1696,6 +1777,39 @@ export const MCP_TOOLS: McpToolDefinition[] = [ ); }, }, + { + name: DAY_TOOL_NAME, + title: "Get one day", + description: + "Fetch one local calendar day of the user's record (YYYY-MM-DD, in the user's own time zone): what ran through it (medications and courses with dose and day n, pauses, an illness with its day n, a cycle phase, anamnesis facts), the readings in that day's window with the user's usual range over the 30 days before (null with too little history), what happened on it (intakes, dose changes, symptoms, lab results, visits, vaccinations, check-ups, documents by kind, mood and screener scores, workouts), and deterministic notable observations (highest or lowest for at least three months, first reading of a kind). Notes, document names and life events are never included. `unavailable` names sections whose module is off (module_disabled). Each result carries `url`, the day in the app. Returns { present: false } with no_data, outside_window (a future date) or invalid_arguments.", + inputShape: { + date: z.string().describe("The local calendar date, YYYY-MM-DD."), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getDayOutput, + async run(ctx, args) { + const parsed = getDayArgsSchema.safeParse(args ?? {}); + if (!parsed.success) { + return { present: false, reason: "invalid_arguments" }; + } + const result = await readDayForTool({ + userId: ctx.userId, + date: parsed.data.date, + reach: UNBOUNDED_REACH, + text: fenceUserText, + }); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: DAY_TOOL_NAME, present: result.present }, + }); + if (!result.present) return result; + return { + present: true, + url: `${resolveBaseOrigin()}/?${DAY_QUERY_PARAM}=${parsed.data.date}`, + data: result.data, + }; + }, + }, // ── Multi-metric fan-out (catalogue: query expressiveness) ────────── { name: "get_metrics", diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts index ce632e10b..f3cadc6ce 100644 --- a/src/lib/mood/linked-context.ts +++ b/src/lib/mood/linked-context.ts @@ -52,6 +52,7 @@ import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { reconstructSleepNights, + type SleepNight, type SleepStageRow, } from "@/lib/analytics/sleep-night"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; @@ -595,3 +596,118 @@ export async function resolveLinkedDayFigures( function valueOf(figure: LinkedFigure): number | null { return figure.present ? figure.value : null; } + +/** One canonical reading of a local day, as the day view reads it. */ +export interface DayReadingRow extends LinkedRow { + unit: string; +} + +/** Every reading of one local day, one source per metric, and its night. */ +export interface DayReadings { + /** Canonical rows per type, oldest first; sleep stages are not here. */ + rowsByType: Map; + /** The night that woke on this day, or null when none was recorded. */ + night: SleepNight | null; + /** The source of the night's last stage row, the night's representative. */ + nightSource: MeasurementSource | null; +} + +/** + * v1.42 (#613) — the day view's readings, through the same engine as the + * mood sheet's linked figures. + * + * The day view is the wider cousin of {@link resolveLinkedDayContext}: every + * type rather than five, and every reading of the day rather than one figure + * per block. It stays in this file so there is still one answer to "what did + * this day hold": the cross-source de-dup is `canonicalRowsOfDay`, the night + * is the `reconstructSleepNights` night whose wake day is this one, and the + * night reads the same generous window the linked block reads, because a + * night starts the evening before. + * + * The readings themselves are cut exactly: `[dayStart, dayEnd)` of the local + * day in `tz`, which is 23, 24 or 25 hours long. A reading at 00:30 belongs + * to the day it was taken on, never to the UTC day the rollups would file it + * under. `typeVisible` is the caller's module (and section) mask; a type it + * refuses is never read. + */ +export async function resolveDayReadings( + userId: string, + day: string, + tz: string, + window: { dayStart: Date; dayEnd: Date }, + typeVisible: (type: MeasurementType) => boolean, + priorityJson?: unknown, +): Promise { + const sleepVisible = typeVisible("SLEEP_DURATION"); + const generous = localDayWindow(day); + const [priority, rows, stageRows] = await Promise.all([ + priorityJson !== undefined + ? Promise.resolve(priorityJson) + : loadUserSourcePriority(userId), + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: { not: "SLEEP_DURATION" }, + measuredAt: { gte: window.dayStart, lt: window.dayEnd }, + }, + select: { + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + deviceType: true, + }, + // Same determinism precondition as the linked block above. + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + }), + sleepVisible + ? prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: "SLEEP_DURATION", + measuredAt: { gte: generous.from, lte: generous.to }, + }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + }) + : Promise.resolve([]), + ]); + + const byType = new Map(); + for (const row of rows) { + if (!typeVisible(row.type)) continue; + const bucket = byType.get(row.type); + if (bucket) bucket.push(row); + else byType.set(row.type, [row]); + } + const rowsByType = new Map(); + for (const [type, typeRows] of byType) { + const canonical = canonicalRowsOfDay(typeRows, type, day, tz, priority); + rowsByType.set(type, canonical as DayReadingRow[]); + } + + const night = + stageRows.length === 0 + ? null + : (reconstructSleepNights( + stageRows as unknown as SleepStageRow[], + tz, + priority, + ).find((n) => n.night === day) ?? null); + const nightSource = night + ? (stageRows.find( + (r) => r.measuredAt.getTime() === night.measuredAt.getTime(), + )?.source ?? null) + : null; + + return { rowsByType, night, nightSource }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 6e5412191..06c7a87bc 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -118,6 +118,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], "disable-coach": ["disable-coach.body.invalid_json"], + day: ["day.invalid"], demo: ["demo.readOnly"], documents: [ "documents.chat.conversationNotFound", @@ -230,6 +231,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "labs.update.referenceRangeInvalid", "labs.update.sourceReferenceRangeInvalid", ], + life_event: ["life_event.invalid", "life_event.not_found"], managed_profile: [ "managed_profile.guardian.duplicate", "managed_profile.guardian.managed_invitee", @@ -380,6 +382,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "symptoms.event.notFound", "symptoms.invalid", ], + timeline: ["timeline.invalid"], tokens: [ "tokens.documents.ceiling_reached", "tokens.measurements.ceiling_reached", @@ -423,7 +426,6 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "modules_conflict", "mood_tag_layout_conflict", "not_configured", - "not_implemented", "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", diff --git a/src/lib/openapi/routes/day.ts b/src/lib/openapi/routes/day.ts index 9bbcd74b3..522af41eb 100644 --- a/src/lib/openapi/routes/day.ts +++ b/src/lib/openapi/routes/day.ts @@ -4,14 +4,15 @@ * * Part of the OpenAPI route table; aggregated in `./index.ts`. The request * and response schemas are the runtime ones from `@/lib/day/wire-schemas`, so - * the published contract is the parser's own. Published ahead of the - * implementation: every operation answers 501 until its route lands. + * the published contract is the parser's own. */ import type { ZodOpenApiObject } from "zod-openapi"; import { dayIndexQuerySchema, dayIndexResponseSchema, + dayNotableQuerySchema, + dayNotableResponseSchema, dayPathSchema, dayResponseSchema, lifeEventCreateSchema, @@ -27,7 +28,8 @@ import { import { dataEnvelope, errorEnvelope, - notImplementedResponse, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -62,7 +64,30 @@ export const dayPaths: NonNullable = { }, ...stdResponses, ...recordRefusal(), - ...notImplementedResponse, + }, + }, + }, + "/api/day/notable": { + get: { + tags: ["Records"], + summary: "Since the last visit", + description: + "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's grant does not cover are left out.", + requestParams: { query: dayNotableQuerySchema }, + responses: { + "200": { + description: "The window, its observations and its changes.", + content: { + "application/json": { + schema: dataEnvelope( + dayNotableResponseSchema, + "DayNotableWindowEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...recordRefusal(), }, }, }, @@ -84,7 +109,6 @@ export const dayPaths: NonNullable = { }, ...stdResponses, ...recordRefusal(), - ...notImplementedResponse, }, }, }, @@ -106,7 +130,6 @@ export const dayPaths: NonNullable = { }, ...stdResponses, ...recordRefusal(TIMELINE_OFF), - ...notImplementedResponse, }, }, }, @@ -130,7 +153,6 @@ export const dayPaths: NonNullable = { }, ...stdResponses, ...recordRefusal(TIMELINE_OFF), - ...notImplementedResponse, }, }, }, @@ -154,10 +176,10 @@ export const dayPaths: NonNullable = { }, ...stdResponses, ...recordRefusal(), - ...notImplementedResponse, }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Records"], summary: "Add a life event", description: @@ -176,8 +198,9 @@ export const dayPaths: NonNullable = { }, }, ...stdResponses, + ...recordRefusal(), ...recordWriteRateLimitResponse, - ...notImplementedResponse, + ...idempotentWrite(), }, }, }, @@ -203,7 +226,7 @@ export const dayPaths: NonNullable = { }, ...lifeEventNotFound, ...stdResponses, - ...notImplementedResponse, + ...recordRefusal(), }, }, delete: { @@ -222,7 +245,7 @@ export const dayPaths: NonNullable = { }, ...lifeEventNotFound, ...stdResponses, - ...notImplementedResponse, + ...recordRefusal(), }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1b4599b4d..79e399cca 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -269,21 +269,6 @@ export const malformedJsonResponse = { }, }; -/** - * The 501 a route answers while its contract is published ahead of its - * implementation on a release branch. Every stub carries it; the owner of the - * route drops it with the stub. No tagged release answers it: the error-code - * catalogue check fails once `not_implemented` is listed and nothing emits - * it, and fails while something still does and it is not listed. - */ -export const notImplementedResponse = { - "501": { - description: - "Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.", - content: { "application/json": { schema: errorEnvelope } }, - }, -} as const; - // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 2c310f04a..f370de57e 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -80,6 +80,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.vaccinationRecordCreate", { name }); case "vaccination.custom.create": return t("recordSharing.activityVerb.customVaccineCreate", { name }); + case "life_event.create": + return t("recordSharing.activityVerb.lifeEventCreate", { name }); case "practitioner.contact.create": return t("recordSharing.activityVerb.practitionerContactCreate", { name, @@ -224,6 +226,10 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.customVaccineUpdate", { name }); case "vaccination.custom.delete": return t("recordSharing.activityVerb.customVaccineDelete", { name }); + case "life_event.update": + return t("recordSharing.activityVerb.lifeEventUpdate", { name }); + case "life_event.delete": + return t("recordSharing.activityVerb.lifeEventDelete", { name }); case "vaccination.booster.planned": return t("recordSharing.activityVerb.vaccinationBoosterPlanned", { name, diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts new file mode 100644 index 000000000..0e0506328 --- /dev/null +++ b/src/lib/timeline/__tests__/series.test.ts @@ -0,0 +1,69 @@ +/** + * Timeline buckets and windows (v1.42, #613). + */ +import { describe, expect, it } from "vitest"; + +import { overlaps, parseSeriesKeys } from "../load-timeline"; +import { coveredWeeks, READINESS_WEEKS } from "../readiness"; +import { bucketKey, foldDays } from "../series"; + +describe("buckets", () => { + it("names a bucket by its first local day, weeks from Monday", () => { + expect(bucketKey("2026-03-29", "month")).toBe("2026-03-01"); + // 2026-03-29 is a Sunday; its week began on Monday the 23rd. + expect(bucketKey("2026-03-29", "week")).toBe("2026-03-23"); + expect(bucketKey("2026-03-23", "week")).toBe("2026-03-23"); + expect(bucketKey("2026-03-29", "day")).toBe("2026-03-29"); + }); + + it("weighs each day once in a bucket", () => { + const points = foldDays( + new Map([ + ["2026-03-01", 100], + ["2026-03-02", 110], + ["2026-04-01", 90], + ]), + "month", + ); + expect(points).toEqual([ + { t: "2026-03-01", mean: 105 }, + { t: "2026-04-01", mean: 90 }, + ]); + }); +}); + +describe("window", () => { + it("keeps an open span up to today and drops what lies outside", () => { + const open = { start: "2020-01-01", end: null, open: true }; + expect(overlaps(open, "2026-01-01", "2026-03-31", "2026-03-31")).toBe(true); + const point = { start: "2025-12-31", end: null, open: false }; + expect(overlaps(point, "2026-01-01", "2026-03-31", "2026-03-31")).toBe( + false, + ); + const closed = { start: "2025-06-01", end: "2026-01-05", open: false }; + expect(overlaps(closed, "2026-01-01", "2026-03-31", "2026-03-31")).toBe( + true, + ); + }); + + it("accepts known series only", () => { + expect(parseSeriesKeys(undefined)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "WEIGHT", + ]); + expect(parseSeriesKeys("WEIGHT,MOOD,WEIGHT")).toEqual(["WEIGHT", "MOOD"]); + expect(parseSeriesKeys("WEIGHT,DROP TABLE")).toBeNull(); + }); +}); + +describe("readiness weeks", () => { + it("counts the weeks of the last thirteen that hold a day", () => { + const today = "2026-03-29"; + expect( + coveredWeeks(["2026-03-29", "2026-03-23", "2026-03-22"], today), + ).toBe(2); + expect(coveredWeeks(["2025-01-01"], today)).toBe(0); + expect(READINESS_WEEKS).toBe(13); + }); +}); diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts new file mode 100644 index 000000000..9b3760674 --- /dev/null +++ b/src/lib/timeline/lanes.ts @@ -0,0 +1,520 @@ +/** + * The timeline's lanes: the record's spans and points, projected from the + * tables that own them (v1.42, #613). + * + * Nothing is stored for the timeline. A medication's line is its start and + * end date, an illness its onset and recovery, a visit its day; correcting + * the row corrects the line. Every item says how sure its start is: a + * medication without a start date starts at its first intake, and says so + * (`startKnown: false`), so the view can draw that start dashed rather than + * pretend. Something with no date at all is not drawn on the axis; it is + * listed once under `standing`. + * + * Each lane belongs to one sharing domain and, where a module owns it, to + * that module; a lane the caller may not see is not read. The `life` lane + * mixes life events (the timeline module's own content, `profile`) with + * travel periods (the environment module, owner only) and is filtered item + * by item. A travel item never carries a place: the label is sealed. + */ +import type { TimelineItem, TimelineLaneKey } from "@/lib/day/contract"; +import { openText } from "@/lib/day/records"; +import { prisma } from "@/lib/db"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { surfaceModule } from "@/lib/modules/surface"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; + +/** The sharing domain each lane reads. */ +export const TIMELINE_LANE_SHARE_DOMAIN: Readonly< + Record +> = Object.freeze({ + life: "profile", + illness: "illness", + allergies: "profile", + medications: "medications", + vaccinations: "profile", + visits: "profile", + labs: "labs", + documents: "documents", + cycle: "cycle", +}); + +/** What one request may see of the timeline. */ +export interface TimelineAccess { + modules: Readonly>; + domainVisible: (domain: ShareDomain) => boolean; + owner: boolean; +} + +export function laneVisible( + lane: TimelineLaneKey, + access: TimelineAccess, +): boolean { + const owner = surfaceModule(`timeline-lane:${lane}`); + if (owner !== undefined && access.modules[owner] === false) return false; + return access.domainVisible(TIMELINE_LANE_SHARE_DOMAIN[lane]); +} + +export interface StandingItem { + lane: TimelineLaneKey; + id: string; + label: string; + since: string | null; + href: string | null; +} + +export interface LaneRead { + items: TimelineItem[]; + standing: StandingItem[]; +} + +interface LaneFrame { + userId: string; + tz: string; + access: TimelineAccess; +} + +function item( + rest: Omit & + Partial>, +): TimelineItem { + return { + precision: "DAY", + startKnown: true, + sub: null, + href: null, + ...rest, + }; +} + +async function lifeLane(frame: LaneFrame): Promise { + const travelVisible = + frame.access.owner && frame.access.modules.environment !== false; + const [events, trips] = await Promise.all([ + frame.access.modules.timeline === false + ? Promise.resolve([]) + : prisma.lifeEvent.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { + id: true, + category: true, + startDate: true, + endDate: true, + precision: true, + titleEncrypted: true, + }, + orderBy: { startDate: "asc" }, + }), + travelVisible + ? prisma.environmentTravelLocation.findMany({ + where: { userId: frame.userId }, + select: { id: true, startDate: true, endDate: true }, + orderBy: { startDate: "asc" }, + }) + : Promise.resolve([]), + ]); + return { + items: [ + ...events.map((e) => + item({ + id: e.id, + kind: "lifeEvent", + start: e.startDate, + end: e.endDate, + open: false, + precision: e.precision, + label: openText(e.titleEncrypted, "life event title") ?? "", + sub: e.category, + }), + ), + ...trips.map((t) => + item({ + id: t.id, + kind: "travel", + start: t.startDate, + end: t.endDate, + open: false, + label: "travel", + }), + ), + ], + standing: [], + }; +} + +async function illnessLane(frame: LaneFrame): Promise { + const rows = await prisma.illnessEpisode.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { + id: true, + label: true, + lifecycle: true, + onsetAt: true, + resolvedAt: true, + }, + orderBy: { onsetAt: "asc" }, + }); + return { + items: rows.map((r) => + item({ + id: r.id, + kind: r.lifecycle === "CHRONIC_ONGOING" ? "chronic" : "episode", + start: userDayKey(r.onsetAt, frame.tz), + end: r.resolvedAt ? userDayKey(r.resolvedAt, frame.tz) : null, + open: r.resolvedAt === null, + label: r.label, + href: `/illness/${r.id}`, + }), + ), + standing: [], + }; +} + +async function allergiesLane(frame: LaneFrame): Promise { + const rows = await prisma.allergy.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { id: true, substance: true, onsetAt: true, status: true }, + orderBy: { createdAt: "asc" }, + }); + const out: LaneRead = { items: [], standing: [] }; + for (const r of rows) { + if (r.onsetAt) { + out.items.push( + item({ + id: r.id, + kind: "allergy", + start: userDayKey(r.onsetAt, frame.tz), + end: null, + open: r.status === "ACTIVE", + label: r.substance, + href: "/profile", + }), + ); + } else if (r.status === "ACTIVE") { + out.standing.push({ + lane: "allergies", + id: r.id, + label: r.substance, + since: null, + href: "/profile", + }); + } + } + return out; +} + +async function medicationsLane(frame: LaneFrame): Promise { + const [meds, firstIntakes, courses, changes, pauses] = await Promise.all([ + prisma.medication.findMany({ + where: { userId: frame.userId }, + select: { + id: true, + name: true, + dose: true, + active: true, + oneShot: true, + startsOn: true, + endsOn: true, + updatedAt: true, + }, + orderBy: { createdAt: "asc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + where: { + userId: frame.userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + ...TRACKED_INTAKE_EVENT_WHERE, + }, + _min: { takenAt: true }, + }), + prisma.medicationCourse.findMany({ + where: { userId: frame.userId }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationDoseChange.findMany({ + where: { medication: { userId: frame.userId } }, + select: { + id: true, + medicationId: true, + effectiveFrom: true, + doseValue: true, + doseUnit: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationPauseEra.findMany({ + where: { userId: frame.userId }, + select: { + id: true, + medicationId: true, + pausedAt: true, + resumedAt: true, + medication: { select: { name: true } }, + }, + }), + ]); + const firstIntake = new Map(); + for (const row of firstIntakes) { + if (row._min.takenAt) firstIntake.set(row.medicationId, row._min.takenAt); + } + const out: LaneRead = { items: [], standing: [] }; + for (const med of meds) { + const href = `/medications/${med.id}`; + const stated = med.startsOn ? dateOnlyKey(med.startsOn) : null; + const standIn = firstIntake.get(med.id); + const start = stated ?? (standIn ? userDayKey(standIn, frame.tz) : null); + if (start === null) { + out.standing.push({ + lane: "medications", + id: med.id, + label: med.name, + since: null, + href, + }); + continue; + } + const end = med.endsOn + ? dateOnlyKey(med.endsOn) + : med.active + ? null + : userDayKey(med.updatedAt, frame.tz); + out.items.push( + item({ + id: med.id, + kind: "medication", + start, + end: med.oneShot ? null : end, + open: !med.oneShot && end === null, + startKnown: stated !== null, + label: med.name, + sub: med.dose, + href, + }), + ); + } + for (const c of courses) { + const end = c.endsOn ? dateOnlyKey(c.endsOn) : null; + out.items.push( + item({ + id: c.id, + kind: "course", + start: dateOnlyKey(c.startsOn), + end, + open: end === null, + label: c.medication.name, + href: `/medications/${c.medicationId}`, + }), + ); + } + for (const d of changes) { + out.items.push( + item({ + id: d.id, + kind: "doseChange", + start: userDayKey(d.effectiveFrom, frame.tz), + end: null, + open: false, + label: d.medication.name, + sub: `${d.doseValue} ${d.doseUnit}`, + href: `/medications/${d.medicationId}/history`, + }), + ); + } + for (const p of pauses) { + const end = p.resumedAt ? userDayKey(p.resumedAt, frame.tz) : null; + out.items.push( + item({ + id: p.id, + kind: "pause", + start: userDayKey(p.pausedAt, frame.tz), + end, + open: end === null, + label: p.medication.name, + href: `/medications/${p.medicationId}`, + }), + ); + } + return out; +} + +async function vaccinationsLane(frame: LaneFrame): Promise { + const rows = await prisma.vaccinationRecord.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { + id: true, + occurredAt: true, + vaccineName: true, + antigenSlug: true, + customVaccine: { select: { name: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + return { + items: rows.map((r) => + item({ + id: r.id, + kind: "vaccination", + start: userDayKey(r.occurredAt, frame.tz), + end: null, + open: false, + label: r.vaccineName ?? r.customVaccine?.name ?? r.antigenSlug ?? "", + href: `/vaccinations?dose=${r.id}`, + }), + ), + standing: [], + }; +} + +async function visitsLane(frame: LaneFrame): Promise { + const rows = await prisma.encounter.findMany({ + where: { userId: frame.userId, deletedAt: null, status: "DONE" }, + select: { + id: true, + occurredAt: true, + kind: true, + reasonEncrypted: true, + practitioner: { select: { name: true } }, + }, + orderBy: { occurredAt: "asc" }, + }); + return { + items: rows.map((r) => + item({ + id: r.id, + kind: r.kind === "PROCEDURE" ? "procedure" : "visit", + start: userDayKey(r.occurredAt, frame.tz), + end: null, + open: false, + label: + openText(r.reasonEncrypted, "visit reason") ?? + r.practitioner?.name ?? + "", + sub: r.kind, + href: `/checkups?visit=${r.id}`, + }), + ), + standing: [], + }; +} + +/** At most this many analytes name a lab day. */ +const LAB_DAY_NAMES = 3; + +async function labsLane(frame: LaneFrame): Promise { + const rows = await prisma.labResult.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { analyte: true, takenAt: true }, + orderBy: { takenAt: "asc" }, + }); + const days = new Map(); + for (const r of rows) { + const day = userDayKey(r.takenAt, frame.tz); + const names = days.get(day) ?? []; + if (!names.includes(r.analyte)) names.push(r.analyte); + days.set(day, names); + } + return { + items: [...days].map(([day, names]) => + item({ + id: `lab-day:${day}`, + kind: "labDay", + start: day, + end: null, + open: false, + label: names.slice(0, LAB_DAY_NAMES).join(", "), + sub: String(names.length), + href: "/labs", + }), + ), + standing: [], + }; +} + +async function documentsLane(frame: LaneFrame): Promise { + const rows = await prisma.inboundDocument.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + OR: [{ reportDate: { not: null } }, { documentDate: { not: null } }], + }, + select: { + id: true, + kind: true, + title: true, + filename: true, + reportDate: true, + documentDate: true, + }, + }); + return { + items: rows.flatMap((r) => { + const date = r.reportDate ?? r.documentDate; + if (!date) return []; + return [ + item({ + id: r.id, + kind: "document", + start: dateOnlyKey(date), + end: null, + open: false, + label: r.title ?? r.filename ?? "", + sub: r.kind, + href: `/documents?doc=${r.id}`, + }), + ]; + }), + standing: [], + }; +} + +async function cycleLane(frame: LaneFrame): Promise { + const rows = await prisma.menstrualCycle.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + isPredicted: false, + absorbedIntoId: null, + }, + select: { id: true, startDate: true, endDate: true }, + orderBy: { startDate: "asc" }, + }); + return { + items: rows.map((r) => + item({ + id: r.id, + kind: "cycle", + start: r.startDate, + end: r.endDate, + open: r.endDate === null, + label: "cycle", + href: "/cycle", + }), + ), + standing: [], + }; +} + +export const LANE_READERS: Readonly< + Record Promise> +> = Object.freeze({ + life: lifeLane, + illness: illnessLane, + allergies: allergiesLane, + medications: medicationsLane, + vaccinations: vaccinationsLane, + visits: visitsLane, + labs: labsLane, + documents: documentsLane, + cycle: cycleLane, +}); diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts new file mode 100644 index 000000000..0b0691b54 --- /dev/null +++ b/src/lib/timeline/load-timeline.ts @@ -0,0 +1,193 @@ +/** + * `loadTimeline`: the record over the years (v1.42, #613). + * + * The lanes read the whole history of their tables (they are small: a few + * hundred spans and points even on a long record), then the window decides + * what is sent. The series and the notable days are the expensive part and + * read only the window. + * + * Windows by zoom, when the request names none: `quarter` the last 91 days, + * `year` the last 365, `all` from the earliest date the record carries. The + * notable days of an `all` window are computed over its last three years at + * most: an extreme is "the highest for at least three months", and older + * history is what the comparison reads, not what it marks. + */ +import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums"; +import type { MeasurementType } from "@/generated/prisma/enums"; + +import { + DAY_NOTABLE_MAX_SPAN_DAYS, + TIMELINE_LANE_KEYS, + type TimelineItem, + type TimelineQuery, + type TimelineResponse, + type TimelineZoom, +} from "@/lib/day/contract"; +import { loadNotableRange } from "@/lib/day/notable"; +import { measurementTypeVisible } from "@/lib/day/sections"; +import { prisma } from "@/lib/db"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + LANE_READERS, + laneVisible, + type StandingItem, + type TimelineAccess, +} from "@/lib/timeline/lanes"; +import { + loadTimelineSeries, + MOOD_SERIES_KEY, + type SeriesGranularity, +} from "@/lib/timeline/series"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** The series sent when the request names none. */ +export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "WEIGHT", +]; + +const ZOOM_GRANULARITY: Readonly> = { + all: "month", + year: "week", + quarter: "day", +}; + +const ZOOM_DAYS: Readonly, number>> = { + year: 365, + quarter: 91, +}; + +const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( + Object.values(MeasurementTypes), +); + +/** + * The series keys a `values` parameter names, or null when one is not a + * series this API knows (the route answers 422). + */ +export function parseSeriesKeys(raw: string | undefined): string[] | null { + if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; + const keys = [ + ...new Set( + raw + .split(",") + .map((k) => k.trim()) + .filter((k) => k.length > 0), + ), + ]; + if (keys.length > 8) return null; + for (const key of keys) { + if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; + } + return keys; +} + +/** Whether an item touches `[from, to]`. An open span runs up to today. */ +export function overlaps( + item: Pick, + from: string, + to: string, + today: string, +): boolean { + const end = item.end ?? (item.open ? today : item.start); + return item.start <= to && end >= from; +} + +export async function loadTimeline(args: { + recordId: string; + query: TimelineQuery; + seriesKeys: readonly string[]; + access: TimelineAccess; + tz: string; + now?: Date; +}): Promise { + const { recordId, query, access, tz } = args; + const now = args.now ?? new Date(); + const today = userDayKey(now, tz); + const frame = { userId: recordId, tz, access }; + + const visibleLanes = TIMELINE_LANE_KEYS.filter((key) => + laneVisible(key, access), + ); + const measurementsVisible = access.domainVisible("measurements"); + const [reads, firstReading] = await Promise.all([ + Promise.all(visibleLanes.map((key) => LANE_READERS[key](frame))), + measurementsVisible + ? prisma.measurement.findFirst({ + where: { userId: recordId, deletedAt: null }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true }, + }) + : Promise.resolve(null), + ]); + + const starts = reads.flatMap((r) => r.items.map((i) => i.start)); + if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz)); + const dataFrom = starts.length > 0 ? starts.sort()[0] : null; + + const to = query.to ?? today; + const from = + query.from ?? + (query.zoom === "all" + ? dataFrom !== null && dataFrom < to + ? dataFrom + : shiftDateKey(to, -364) + : shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1))); + + const lanes: TimelineResponse["lanes"] = []; + const standing: StandingItem[] = []; + visibleLanes.forEach((key, i) => { + const items = reads[i].items + .filter((it) => overlaps(it, from, to, today)) + .sort((a, b) => a.start.localeCompare(b.start)); + if (items.length > 0) lanes.push({ key, items }); + standing.push(...reads[i].standing); + }); + + const seriesKeys = args.seriesKeys.filter((key) => + key === MOOD_SERIES_KEY + ? access.modules.mood !== false && access.domainVisible("mind") + : measurementsVisible && + measurementTypeVisible(key as MeasurementType, access.modules), + ); + const priorityJson = measurementsVisible + ? await loadUserSourcePriority(recordId) + : null; + const notableFrom = + shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) > from + ? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) + : from; + const [series, notable] = await Promise.all([ + loadTimelineSeries({ + userId: recordId, + keys: seriesKeys, + from, + to, + tz, + granularity: ZOOM_GRANULARITY[query.zoom], + priorityJson, + now, + }), + measurementsVisible + ? loadNotableRange({ + userId: recordId, + from: notableFrom, + to, + tz, + priorityJson, + typeVisible: (t) => measurementTypeVisible(t, access.modules), + gaps: false, + }) + : Promise.resolve([]), + ]); + + return { + zoom: query.zoom, + range: { from, to, dataFrom }, + lanes, + standing, + series, + notable: notable.map(({ date, kind }) => ({ date, kind })), + }; +} diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts new file mode 100644 index 000000000..701a85a9f --- /dev/null +++ b/src/lib/timeline/readiness.ts @@ -0,0 +1,474 @@ +/** + * What the timeline can already show, lane by lane (v1.42, #613). + * + * Counts and keys, never a score. Each lane is `carries`, `thin` or `empty` + * by a rule a person can check against their own record, and each gap names + * exactly one in-app place where it can be closed. The verdict is one of two + * words: `carries` from three carrying lanes including the values, `thin` + * otherwise. A lane of a switched-off module is not listed at all, not even + * as a gap, and neither is one the caller's grant does not cover. + * + * values a core value on at least half of the last 13 weeks + * illness at least one episode (every episode has an onset) + * allergies at least one with an onset; only undated ones is thin and + * is no gap, a lasting allergy needs no date + * medications every medication has a start (or a first intake to stand + * in); some without one is thin, with a link to the first + * vaccinations at least one dose + * visits at least one completed visit; only planned ones is thin + * labs at least one result + * documents at least one dated document; only undated ones is thin + * cycle, mood, environment + * entries on at least half of the last 13 weeks + * life at least one life event + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import type { + TimelineReadinessKey, + TimelineReadinessLane, + TimelineReadinessResponse, +} from "@/lib/day/contract"; +import { readLocalDaysWithReadings } from "@/lib/day/daily-stats"; +import { prisma } from "@/lib/db"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import { measurementTypeVisible } from "@/lib/day/sections"; +import { laneVisible, type TimelineAccess } from "@/lib/timeline/lanes"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { bucketKey } from "@/lib/timeline/series"; + +/** The values that make the values lane carry. */ +const CORE_VALUE_TYPES: readonly MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "RESTING_HEART_RATE", + "BLOOD_GLUCOSE", +]; + +/** The weeks a regular lane is judged over. */ +export const READINESS_WEEKS = 13; + +type LaneResult = Omit & { + /** The earliest date the lane reaches back to, when it carries. */ + since: string | null; +}; + +function lane( + status: LaneResult["status"], + count: number, + since: string | null, + extra: Partial> = {}, +): LaneResult { + return { status, count, since, detail: null, gaps: [], ...extra }; +} + +/** How many of the last {@link READINESS_WEEKS} weeks hold a day. */ +export function coveredWeeks(days: Iterable, today: string): number { + const first = shiftDateKey(today, -7 * READINESS_WEEKS + 1); + const weeks = new Set(); + for (const day of days) { + if (day >= first && day <= today) weeks.add(bucketKey(day, "week")); + } + return weeks.size; +} + +function regular( + weeks: number, + total: number, + since: string | null, + gap: { key: string; href: string } | null, +): LaneResult { + const status = + weeks * 2 >= READINESS_WEEKS ? "carries" : total > 0 ? "thin" : "empty"; + return lane(status, total, status === "carries" ? since : null, { + detail: { key: "weeks", params: { weeks, of: READINESS_WEEKS } }, + gaps: status === "carries" || gap === null ? [] : [{ ...gap, count: 0 }], + }); +} + +interface Frame { + userId: string; + tz: string; + today: string; + /** The regular-lane window, `[windowStart, windowEnd)`. */ + windowStart: Date; + windowEnd: Date; +} + +async function valuesLane(frame: Frame, types: MeasurementType[]) { + if (types.length === 0) return lane("empty", 0, null); + const [days, first] = await Promise.all([ + readLocalDaysWithReadings({ + userId: frame.userId, + types, + from: frame.windowStart, + to: frame.windowEnd, + tz: frame.tz, + }), + prisma.measurement.findFirst({ + where: { userId: frame.userId, deletedAt: null, type: { in: types } }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true }, + }), + ]); + return regular( + coveredWeeks(days, frame.today), + days.size, + first ? userDayKey(first.measuredAt, frame.tz) : null, + { key: "connectSources", href: "/settings/sources" }, + ); +} + +async function moodLane(frame: Frame) { + const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1); + const [rows, earliest] = await Promise.all([ + prisma.moodEntry.findMany({ + where: { userId: frame.userId, deletedAt: null, date: { gte: first } }, + select: { date: true }, + distinct: ["date"], + }), + prisma.moodEntry.findFirst({ + where: { userId: frame.userId, deletedAt: null }, + orderBy: { date: "asc" }, + select: { date: true }, + }), + ]); + return regular( + coveredWeeks( + rows.map((r) => r.date), + frame.today, + ), + rows.length, + earliest?.date ?? null, + { key: "logMood", href: "/mood" }, + ); +} + +async function cycleLane(frame: Frame) { + const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1); + const [rows, earliest] = await Promise.all([ + prisma.cycleDayLog.findMany({ + where: { userId: frame.userId, deletedAt: null, date: { gte: first } }, + select: { date: true }, + }), + prisma.menstrualCycle.findFirst({ + where: { userId: frame.userId, deletedAt: null, isPredicted: false }, + orderBy: { startDate: "asc" }, + select: { startDate: true }, + }), + ]); + return regular( + coveredWeeks( + rows.map((r) => r.date), + frame.today, + ), + rows.length, + earliest?.startDate ?? null, + { key: "logCycle", href: "/cycle" }, + ); +} + +async function environmentLane(frame: Frame) { + const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1); + const [rows, earliest] = await Promise.all([ + prisma.environmentContext.findMany({ + where: { userId: frame.userId, date: { gte: first } }, + select: { date: true }, + }), + prisma.environmentContext.findFirst({ + where: { userId: frame.userId }, + orderBy: { date: "asc" }, + select: { date: true }, + }), + ]); + return regular( + coveredWeeks( + rows.map((r) => r.date), + frame.today, + ), + rows.length, + earliest?.date ?? null, + { key: "setHome", href: "/settings/environment" }, + ); +} + +async function illnessLane(frame: Frame) { + const [count, first] = await Promise.all([ + prisma.illnessEpisode.count({ + where: { userId: frame.userId, deletedAt: null }, + }), + prisma.illnessEpisode.findFirst({ + where: { userId: frame.userId, deletedAt: null }, + orderBy: { onsetAt: "asc" }, + select: { onsetAt: true }, + }), + ]); + return count > 0 + ? lane("carries", count, first ? userDayKey(first.onsetAt, frame.tz) : null) + : lane("empty", 0, null, { + gaps: [{ key: "addEpisode", count: 0, href: "/illness" }], + }); +} + +async function allergiesLane(frame: Frame) { + const rows = await prisma.allergy.findMany({ + where: { userId: frame.userId, deletedAt: null }, + select: { onsetAt: true }, + }); + const dated = rows.filter((r) => r.onsetAt !== null); + if (dated.length > 0) { + const since = dated + .map((r) => userDayKey(r.onsetAt as Date, frame.tz)) + .sort()[0]; + return lane("carries", rows.length, since); + } + // Undated allergies are standing items, not a shortcoming: no gap. + return lane(rows.length > 0 ? "thin" : "empty", rows.length, null); +} + +async function medicationsLane(frame: Frame) { + const [meds, intakes] = await Promise.all([ + prisma.medication.findMany({ + where: { userId: frame.userId }, + select: { id: true, startsOn: true }, + orderBy: { createdAt: "asc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + where: { + userId: frame.userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + ...TRACKED_INTAKE_EVENT_WHERE, + }, + _min: { takenAt: true }, + }), + ]); + if (meds.length === 0) { + return lane("empty", 0, null, { + gaps: [{ key: "addMedication", count: 0, href: "/medications/new" }], + }); + } + const firstIntake = new Map( + intakes.map((r) => [r.medicationId, r._min.takenAt]), + ); + const starts: string[] = []; + const missing: string[] = []; + for (const med of meds) { + // A first intake stands in for a missing start, as on the lane itself. + const intake = firstIntake.get(med.id); + if (med.startsOn) starts.push(dateOnlyKey(med.startsOn)); + else if (intake) starts.push(userDayKey(intake, frame.tz)); + else missing.push(med.id); + } + const since = starts.sort()[0] ?? null; + if (missing.length === 0) return lane("carries", meds.length, since); + return lane("thin", meds.length, null, { + detail: { + key: "startMissing", + params: { missing: missing.length, total: meds.length }, + }, + gaps: [ + { + key: "medicationStartMissing", + count: missing.length, + href: `/medications/${missing[0]}?edit=1`, + }, + ], + }); +} + +async function vaccinationsLane(frame: Frame) { + const first = await prisma.vaccinationRecord.findFirst({ + where: { userId: frame.userId, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + select: { occurredAt: true }, + }); + if (!first) { + return lane("empty", 0, null, { + gaps: [{ key: "addVaccination", count: 0, href: "/vaccinations" }], + }); + } + const count = await prisma.vaccinationRecord.count({ + where: { userId: frame.userId, deletedAt: null }, + }); + return lane("carries", count, userDayKey(first.occurredAt, frame.tz)); +} + +async function visitsLane(frame: Frame) { + const [done, planned, first] = await Promise.all([ + prisma.encounter.count({ + where: { userId: frame.userId, deletedAt: null, status: "DONE" }, + }), + prisma.encounter.count({ + where: { userId: frame.userId, deletedAt: null, status: "PLANNED" }, + }), + prisma.encounter.findFirst({ + where: { userId: frame.userId, deletedAt: null, status: "DONE" }, + orderBy: { occurredAt: "asc" }, + select: { occurredAt: true }, + }), + ]); + if (done > 0) { + return lane( + "carries", + done, + first ? userDayKey(first.occurredAt, frame.tz) : null, + ); + } + return lane(planned > 0 ? "thin" : "empty", planned, null, { + ...(planned > 0 + ? { detail: { key: "plannedOnly", params: { planned } } } + : {}), + gaps: [{ key: "addVisit", count: 0, href: "/checkups" }], + }); +} + +async function labsLane(frame: Frame) { + const first = await prisma.labResult.findFirst({ + where: { userId: frame.userId, deletedAt: null }, + orderBy: { takenAt: "asc" }, + select: { takenAt: true }, + }); + if (!first) { + return lane("empty", 0, null, { + gaps: [{ key: "addLabs", count: 0, href: "/labs" }], + }); + } + const count = await prisma.labResult.count({ + where: { userId: frame.userId, deletedAt: null }, + }); + return lane("carries", count, userDayKey(first.takenAt, frame.tz)); +} + +async function documentsLane(frame: Frame) { + const [dated, undated] = await Promise.all([ + prisma.inboundDocument.findMany({ + where: { + userId: frame.userId, + deletedAt: null, + OR: [{ reportDate: { not: null } }, { documentDate: { not: null } }], + }, + select: { reportDate: true, documentDate: true }, + }), + prisma.inboundDocument.count({ + where: { + userId: frame.userId, + deletedAt: null, + reportDate: null, + documentDate: null, + }, + }), + ]); + const gaps = + undated > 0 + ? [{ key: "undatedDocuments", count: undated, href: "/documents" }] + : []; + if (dated.length > 0) { + const since = dated + .map((d) => dateOnlyKey((d.reportDate ?? d.documentDate) as Date)) + .sort()[0]; + return lane("carries", dated.length, since, { gaps }); + } + return lane(undated > 0 ? "thin" : "empty", undated, null, { + gaps: + undated > 0 + ? gaps + : [{ key: "addDocument", count: 0, href: "/documents" }], + }); +} + +async function lifeLane(frame: Frame) { + const [count, first] = await Promise.all([ + prisma.lifeEvent.count({ + where: { userId: frame.userId, deletedAt: null }, + }), + prisma.lifeEvent.findFirst({ + where: { userId: frame.userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + select: { startDate: true }, + }), + ]); + return count > 0 + ? lane("carries", count, first?.startDate ?? null) + : lane("empty", 0, null, { + gaps: [ + { key: "addLifeEvent", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }); +} + +export async function loadTimelineReadiness(args: { + recordId: string; + tz: string; + access: TimelineAccess; + now?: Date; +}): Promise { + const { access, tz } = args; + const today = userDayKey(args.now ?? new Date(), tz); + const frame: Frame = { + userId: args.recordId, + tz, + today, + windowStart: startOfLocalDayKey( + shiftDateKey(today, -7 * READINESS_WEEKS + 1), + tz, + ), + windowEnd: startOfLocalDayKey(shiftDateKey(today, 1), tz), + }; + const valueTypes = access.domainVisible("measurements") + ? CORE_VALUE_TYPES.filter((t) => measurementTypeVisible(t, access.modules)) + : []; + + const readers: Array<[TimelineReadinessKey, () => Promise]> = [ + ["values", () => valuesLane(frame, valueTypes)], + ]; + const laneReaders: Record< + Exclude, + () => Promise + > = { + life: () => lifeLane(frame), + illness: () => illnessLane(frame), + allergies: () => allergiesLane(frame), + medications: () => medicationsLane(frame), + vaccinations: () => vaccinationsLane(frame), + visits: () => visitsLane(frame), + labs: () => labsLane(frame), + documents: () => documentsLane(frame), + cycle: () => cycleLane(frame), + }; + for (const [key, read] of Object.entries(laneReaders) as Array< + [keyof typeof laneReaders, () => Promise] + >) { + if (laneVisible(key, access)) readers.push([key, read]); + } + if (access.modules.mood !== false && access.domainVisible("mind")) { + readers.push(["mood", () => moodLane(frame)]); + } + // The environment rows have no delegable route: the owner's lane only. + if (access.modules.environment !== false && access.owner) { + readers.push(["environment", () => environmentLane(frame)]); + } + + const results = await Promise.all(readers.map(([, read]) => read())); + const lanes = readers.map(([key], i) => { + const { since: _since, ...rest } = results[i]; + return { key, ...rest }; + }); + const carrying = readers + .map(([key], i) => ({ key, result: results[i] })) + .filter(({ result }) => result.status === "carries"); + const verdict = + carrying.length >= 3 && carrying.some(({ key }) => key === "values") + ? "carries" + : "thin"; + const sinces = carrying + .map(({ result }) => result.since) + .filter((d): d is string => d !== null) + .sort(); + return { verdict, since: sinces[0] ?? null, lanes }; +} diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts new file mode 100644 index 000000000..aa51c1024 --- /dev/null +++ b/src/lib/timeline/series.ts @@ -0,0 +1,264 @@ +/** + * Value series for the timeline: monthly, weekly or daily means beside the + * lanes (v1.42, #613). + * + * Three ways in, by zoom: + * + * - **all (month)**: the months of the last five years come from the rollup + * tier, MONTH buckets folded from the canonical-source DAY rows. Those + * buckets cut at UTC midnight, so a reading in the first or last hours of + * a month can sit in the neighbouring month. For a monthly mean over + * dozens of days that is a rounding matter, and it is accepted here; the + * day view, where it is not, never reads the rollups. The rollups fold + * only five years (`ROLLUP_FOLD_WINDOW_MS`); the months before that come + * from one live query over the record's own local days, cached per record + * in the analytics bucket, which every measurement write marks stale. + * - **year (week)** and **quarter (day)**: live over the record's local + * days, a year at most. + * + * Every value is the mean of the bucket's days, each day weighing one (a + * cumulative type's days are its day totals), matching the charts. A series + * without a point is not sent. `MOOD` is the mood score, read from the + * entries' own dates. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; + +import { caches, cached } from "@/lib/cache/server-cache"; +import { readLocalDailyStats } from "@/lib/day/daily-stats"; +import type { TimelineSeries } from "@/lib/day/contract"; +import { prisma } from "@/lib/db"; +import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; +import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read"; +import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +export type SeriesGranularity = TimelineSeries["granularity"]; + +/** + * How long the months before the fold window stay cached. They change only + * when an old reading is imported or deleted, and either write marks the + * entry stale at once. + */ +const PRE_FOLD_TTL_MS = 6 * 3_600_000; + +/** The series key for the mood score. */ +export const MOOD_SERIES_KEY = "MOOD"; + +/** The bucket a local day belongs to, named by its first day. */ +export function bucketKey(day: string, granularity: SeriesGranularity): string { + if (granularity === "day") return day; + if (granularity === "month") return `${day.slice(0, 7)}-01`; + // Weeks start on Monday. + const offset = (weekdayOfDateKey(day) + 6) % 7; + return shiftDateKey(day, -offset); +} + +/** Fold a day series into buckets: the mean of the bucket's days. */ +export function foldDays( + days: ReadonlyMap, + granularity: SeriesGranularity, +): Array<{ t: string; mean: number }> { + const buckets = new Map(); + for (const [day, value] of days) { + const key = bucketKey(day, granularity); + const slot = buckets.get(key) ?? { sum: 0, n: 0 }; + slot.sum += value; + slot.n += 1; + buckets.set(key, slot); + } + return [...buckets] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([t, { sum, n }]) => ({ t, mean: Math.round((sum / n) * 100) / 100 })); +} + +async function latestUnit( + userId: string, + type: MeasurementType, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { unit: true }, + }); + return row?.unit ?? null; +} + +/** Months older than the rollup fold window, live and cached. */ +async function preFoldMonths(args: { + userId: string; + type: MeasurementType; + tz: string; + from: Date; + boundary: Date; + priorityJson: unknown; +}): Promise> { + if (args.from >= args.boundary) return []; + // Keyed under the record's `${userId}|` prefix: every measurement write + // marks it stale with the rest of the analytics bucket. + const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`; + return cached( + caches.analytics, + key, + async () => { + const stats = await readLocalDailyStats({ + userId: args.userId, + types: [args.type], + from: args.from, + to: args.boundary, + tz: args.tz, + priorityJson: args.priorityJson, + }); + return foldDays(stats.get(args.type) ?? new Map(), "month"); + }, + undefined, + PRE_FOLD_TTL_MS, + ) as Promise>; +} + +async function monthlyFromRollups(args: { + userId: string; + type: MeasurementType; + from: Date; + to: Date; + priorityJson: unknown; +}): Promise> { + const rows = await readCanonicalRollupBuckets({ + userId: args.userId, + type: args.type, + granularity: "MONTH", + from: args.from, + to: args.to, + toInclusive: true, + userPriorityJson: args.priorityJson, + }); + const cumulative = isCumulativeDaySumType(args.type); + return rows.map((r) => { + const value = cumulative + ? (r.sumValue ?? r.mean * r.count) / Math.max(1, r.days) + : Number.isFinite(r.dayMean) + ? r.dayMean + : r.mean; + return { + t: `${dateOnlyKey(r.bucketStart).slice(0, 7)}-01`, + mean: Math.round(value * 100) / 100, + }; + }); +} + +async function measurementSeries(args: { + userId: string; + type: MeasurementType; + from: string; + to: string; + tz: string; + granularity: SeriesGranularity; + priorityJson: unknown; + now: Date; +}): Promise { + const start = startOfLocalDayKey(args.from, args.tz); + const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); + let points: Array<{ t: string; mean: number }>; + if (args.granularity === "month") { + // The fold boundary rounded up to a month start, so no month is half + // live and half rolled up. + const raw = new Date(args.now.getTime() - ROLLUP_FOLD_WINDOW_MS); + const boundaryKey = bucketKey( + shiftDateKey(userDayKey(raw, args.tz), 31), + "month", + ); + const boundary = startOfLocalDayKey(boundaryKey, args.tz); + const [older, recent] = await Promise.all([ + preFoldMonths({ + userId: args.userId, + type: args.type, + tz: args.tz, + from: start, + boundary: boundary < end ? boundary : end, + priorityJson: args.priorityJson, + }), + end > boundary + ? monthlyFromRollups({ + userId: args.userId, + type: args.type, + from: boundary > start ? boundary : start, + to: end, + priorityJson: args.priorityJson, + }) + : Promise.resolve([]), + ]); + const merged = new Map(); + for (const p of [...older, ...recent]) merged.set(p.t, p.mean); + points = [...merged] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([t, mean]) => ({ t, mean })); + } else { + const stats = await readLocalDailyStats({ + userId: args.userId, + types: [args.type], + from: start, + to: end, + tz: args.tz, + priorityJson: args.priorityJson, + }); + points = foldDays(stats.get(args.type) ?? new Map(), args.granularity); + } + if (points.length === 0) return null; + return { + key: args.type, + unit: await latestUnit(args.userId, args.type), + granularity: args.granularity, + points, + }; +} + +async function moodSeries(args: { + userId: string; + from: string; + to: string; + granularity: SeriesGranularity; +}): Promise { + const rows = await prisma.moodEntry.groupBy({ + by: ["date"], + where: { + userId: args.userId, + deletedAt: null, + date: { gte: args.from, lte: args.to }, + }, + _avg: { score: true }, + }); + const days = new Map(); + for (const row of rows) { + if (row._avg.score !== null) days.set(row.date, row._avg.score); + } + const points = foldDays(days, args.granularity); + if (points.length === 0) return null; + return { + key: MOOD_SERIES_KEY, + unit: null, + granularity: args.granularity, + points, + }; +} + +export async function loadTimelineSeries(args: { + userId: string; + keys: readonly string[]; + from: string; + to: string; + tz: string; + granularity: SeriesGranularity; + priorityJson: unknown; + now?: Date; +}): Promise { + const now = args.now ?? new Date(); + const out = await Promise.all( + args.keys.map((key) => + key === MOOD_SERIES_KEY + ? moodSeries(args) + : measurementSeries({ ...args, type: key as MeasurementType, now }), + ), + ); + return out.filter((s): s is TimelineSeries => s !== null); +} diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 5d70af0c0..58eabf4ab 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -482,6 +482,31 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "delete", ), + // v1.42 (#613) — life events, MANAGE on all three verbs. + handler( + "app/api/life-events/route.ts", + "/api/life-events", + "POST", + "profile", + "manage", + "create", + ), + handler( + "app/api/life-events/[id]/route.ts", + "/api/life-events/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/life-events/[id]/route.ts", + "/api/life-events/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts new file mode 100644 index 000000000..cab2b746b --- /dev/null +++ b/tests/integration/day-view.test.ts @@ -0,0 +1,719 @@ +/** + * v1.42 (#613) — the day view, its index, the visit preparation, the + * timeline and life events, driven as routes against real Postgres. + * + * What the unit tests cannot show: that each of the three ways this schema + * stores a day lands on the right local day across a clock change, that a + * switched-off module's rows are not in the answer, that a grant scoped to + * some sections reads exactly those sections, that a life event is encrypted + * at rest, audited, and never reaches the model-facing read of the same day. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, 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: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +let counter = 0; + +async function makeUser(label: string, modules: Record = {}) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `dv-${suffix}`, + email: `dv-${suffix}@example.test`, + displayName: `DV ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + modulePreferencesJson: modules, + }, + }); +} + +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); + return session; +} + +async function shareWith( + ownerId: string, + delegateId: string, + scope: Array< + | "measurements" + | "medications" + | "labs" + | "profile" + | "illness" + | "mind" + | "cycle" + | "documents" + > | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegateId }); + const session = await signIn(delegateId); + await switchSessionTo(session.id, ownerId); +} + +async function call( + handler: Handler, + method: string, + url: string, + body?: unknown, + params: Record = {}, +): Promise { + return handler( + new NextRequest(`http://localhost${url}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + { params: Promise.resolve(params) }, + ); +} + +async function json(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} + +async function getDay(date: string) { + const { GET } = await import("@/app/api/day/[date]/route"); + return call(GET as Handler, "GET", `/api/day/${date}`, undefined, { date }); +} + +type Day = import("@/lib/day/contract").DayResponse; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("a local day across a clock change", () => { + it("cuts readings, date strings and calendar dates on the record's own day", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tz"); + // 00:30 local on the spring-forward day is still the 28th in UTC. + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-03-28T23:30:00.000Z"), + }, + }); + // 23:59 local (CEST) is inside the 23-hour day; midnight is not. + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 81, + unit: "kg", + measuredAt: new Date("2026-03-29T21:59:00.000Z"), + }, + }); + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 82, + unit: "kg", + measuredAt: new Date("2026-03-29T22:00:00.000Z"), + }, + }); + // A mood entry filed in New York on the 29th, logged when Berlin was + // already on the 30th: the row's own date decides. + await db.moodEntry.create({ + data: { + userId: owner.id, + date: "2026-03-29", + tz: "America/New_York", + mood: "GUT", + score: 4, + moodLoggedAt: new Date("2026-03-30T02:00:00.000Z"), + }, + }); + // A calendar date: starts on the 29th whatever the zone. + await db.medication.create({ + data: { + userId: owner.id, + name: "Ramipril", + dose: "5 mg", + startsOn: dayKeyAsUtcMidnight("2026-03-29"), + }, + }); + await signIn(owner.id); + + const day = await json(await getDay("2026-03-29")); + expect(day.tz).toBe("Europe/Berlin"); + expect(day.values.map((v) => v.value).sort()).toEqual([80, 81]); + expect(day.events.map((e) => e.kind)).toEqual( + expect.arrayContaining(["mood", "medicationStart"]), + ); + expect(day.running.find((r) => r.kind === "medication")).toMatchObject({ + title: "Ramipril", + since: "2026-03-29", + dayIndex: 1, + }); + + const before = await json(await getDay("2026-03-28")); + expect(before.values).toEqual([]); + expect(before.events.some((e) => e.kind === "mood")).toBe(false); + + const after = await json(await getDay("2026-03-30")); + expect(after.values.map((v) => v.value)).toEqual([82]); + expect(after.events.some((e) => e.kind === "mood")).toBe(false); + }); + + it("holds 25 hours on the fall-back day", async () => { + const db = getPrismaClient(); + const owner = await makeUser("fall"); + // 23:30 CET on the 25th, after the extra hour. + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 79, + unit: "kg", + measuredAt: new Date("2026-10-25T22:30:00.000Z"), + }, + }); + // 00:30 CEST on the 25th, before the change. + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 78, + unit: "kg", + measuredAt: new Date("2026-10-24T22:30:00.000Z"), + }, + }); + await signIn(owner.id); + const day = await json(await getDay("2026-10-25")); + expect(day.values.map((v) => v.value).sort()).toEqual([78, 79]); + }); + + it("refuses a date that is not a calendar date", async () => { + const owner = await makeUser("bad"); + await signIn(owner.id); + const response = await getDay("2026-02-30"); + expect(response.status).toBe(422); + }); +}); + +describe("modules", () => { + it("leaves a switched-off module's rows out without naming them", async () => { + const db = getPrismaClient(); + const owner = await makeUser("mod", { mood: false, recovery: false }); + await db.moodEntry.create({ + data: { + userId: owner.id, + date: "2026-03-10", + tz: "Europe/Berlin", + mood: "GUT", + score: 4, + moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"), + }, + }); + await db.measurement.create({ + data: { + userId: owner.id, + type: "HEART_RATE_VARIABILITY", + value: 45, + unit: "ms", + measuredAt: new Date("2026-03-10T06:00:00.000Z"), + }, + }); + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-03-10T06:00:00.000Z"), + }, + }); + await signIn(owner.id); + const day = await json(await getDay("2026-03-10")); + expect(day.events).toEqual([]); + expect(day.values.map((v) => v.type)).toEqual(["WEIGHT"]); + expect(day.sections).toEqual({}); + }); + + it("answers the timeline only with the opt-in module on", async () => { + const off = await makeUser("tl-off"); + await signIn(off.id); + const { GET } = await import("@/app/api/timeline/route"); + const refused = await call(GET as Handler, "GET", "/api/timeline"); + expect(refused.status).toBe(403); + + const on = await makeUser("tl-on", { timeline: true }); + await signIn(on.id); + const ok = await call(GET as Handler, "GET", "/api/timeline"); + expect(ok.status).toBe(200); + }); +}); + +describe("sharing", () => { + async function seedOwner() { + const db = getPrismaClient(); + const owner = await makeUser("share"); + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-03-10T06:00:00.000Z"), + }, + }); + const med = await db.medication.create({ + data: { userId: owner.id, name: "Ramipril", dose: "5 mg" }, + }); + await db.medicationIntakeEvent.create({ + data: { + userId: owner.id, + medicationId: med.id, + scheduledFor: new Date("2026-03-10T07:00:00.000Z"), + takenAt: new Date("2026-03-10T07:05:00.000Z"), + }, + }); + await db.moodEntry.create({ + data: { + userId: owner.id, + date: "2026-03-10", + tz: "Europe/Berlin", + mood: "GUT", + score: 4, + moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"), + }, + }); + return owner; + } + + it("reads only the sections a scoped grant covers and names the rest", async () => { + const owner = await seedOwner(); + const delegate = await makeUser("scoped"); + await shareWith(owner.id, delegate.id, ["measurements"]); + const response = await getDay("2026-03-10"); + expect(response.status).toBe(200); + const day = await json(response); + expect(day.values.map((v) => v.value)).toEqual([80]); + expect(day.events).toEqual([]); + expect(day.sections.medications).toEqual({ + available: false, + reason: "not_shared", + }); + expect(day.sections.mood).toEqual({ + available: false, + reason: "not_shared", + }); + expect(day.sections.values).toBeUndefined(); + }); + + it("opens the covered sections of a wider grant", async () => { + const owner = await seedOwner(); + const delegate = await makeUser("wider"); + await shareWith(owner.id, delegate.id, ["measurements", "medications"]); + const day = await json(await getDay("2026-03-10")); + expect(day.events.map((e) => e.kind)).toEqual(["intake"]); + expect(day.sections.mood?.reason).toBe("not_shared"); + }); + + it("refuses a grant that does not open the readings", async () => { + const owner = await seedOwner(); + const delegate = await makeUser("mind-only"); + await shareWith(owner.id, delegate.id, ["mind"]); + expect((await getDay("2026-03-10")).status).toBe(403); + }); + + it("gives a full grant the record but keeps the environment for the owner", async () => { + const owner = await seedOwner(); + const delegate = await makeUser("full"); + await shareWith(owner.id, delegate.id, null); + const day = await json(await getDay("2026-03-10")); + expect(day.events.map((e) => e.kind).sort()).toEqual(["intake", "mood"]); + expect(day.sections).toEqual({ + environment: { available: false, reason: "not_shared" }, + }); + }); + + it("narrows the day index the same way", async () => { + const owner = await seedOwner(); + const delegate = await makeUser("index"); + await shareWith(owner.id, delegate.id, ["measurements"]); + const { GET } = await import("@/app/api/day/index/route"); + const index = await json<{ days: Record }>( + await call( + GET as Handler, + "GET", + "/api/day/index?from=2026-03-01&to=2026-03-31", + ), + ); + expect(index.days).toEqual({ "2026-03-10": ["values"] }); + }); +}); + +describe("life events", () => { + it("encrypts, audits, shows in the day and never reaches the model read", async () => { + const db = getPrismaClient(); + const owner = await makeUser("le", { timeline: true }); + await signIn(owner.id); + const { POST, GET } = await import("@/app/api/life-events/route"); + const created = await call(POST as Handler, "POST", "/api/life-events", { + category: "LOSS", + startDate: "2026-03-10", + precision: "DAY", + title: "Zqx grandmother", + note: "Zqx note", + }); + expect(created.status).toBe(201); + const event = await json<{ id: string; title: string }>(created); + expect(event.title).toBe("Zqx grandmother"); + + const row = await db.lifeEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(Buffer.from(row.titleEncrypted).toString("utf8")).not.toContain( + "Zqx", + ); + const audit = await db.auditLog.findFirst({ + where: { action: "life_event.create" }, + }); + expect(audit?.details ?? "").not.toContain("Zqx"); + + const list = await json<{ events: Array<{ title: string }> }>( + await call(GET as Handler, "GET", "/api/life-events"), + ); + expect(list.events.map((e) => e.title)).toEqual(["Zqx grandmother"]); + + const day = await json(await getDay("2026-03-10")); + expect(day.events.map((e) => e.kind)).toEqual(["lifeEvent"]); + + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-03-10T06:00:00.000Z"), + }, + }); + const { executeCoachTool } = await import("@/lib/ai/coach/tools/executor"); + const forModel = await executeCoachTool({ + userId: owner.id, + name: "get_day", + rawArguments: JSON.stringify({ date: "2026-03-10" }), + }); + expect(forModel.present).toBe(true); + expect(JSON.stringify(forModel)).not.toMatch(/Zqx|lifeEvent/); + + // Edit with a misaligned precision is refused; a valid one lands. + const { PATCH, DELETE } = await import("@/app/api/life-events/[id]/route"); + const bad = await call( + PATCH as Handler, + "PATCH", + `/api/life-events/${event.id}`, + { precision: "YEAR" }, + { id: event.id }, + ); + expect(bad.status).toBe(422); + const good = await call( + PATCH as Handler, + "PATCH", + `/api/life-events/${event.id}`, + { category: "FAMILY" }, + { id: event.id }, + ); + expect(good.status).toBe(200); + const removed = await call( + DELETE as Handler, + "DELETE", + `/api/life-events/${event.id}`, + undefined, + { id: event.id }, + ); + expect(removed.status).toBe(200); + expect( + (await db.lifeEvent.findUniqueOrThrow({ where: { id: event.id } })) + .deletedAt, + ).not.toBeNull(); + const again = await call( + DELETE as Handler, + "DELETE", + `/api/life-events/${event.id}`, + undefined, + { id: event.id }, + ); + expect(again.status).toBe(404); + }); + + it("refuses another record's event as not found", async () => { + const db = getPrismaClient(); + const a = await makeUser("a"); + const b = await makeUser("b"); + const theirs = await db.lifeEvent.create({ + data: { + userId: a.id, + category: "HOME", + startDate: "2020-01-01", + precision: "YEAR", + titleEncrypted: encryptToBytes("Moved"), + }, + }); + await signIn(b.id); + const { PATCH } = await import("@/app/api/life-events/[id]/route"); + const response = await call( + PATCH as Handler, + "PATCH", + `/api/life-events/${theirs.id}`, + { category: "WORK" }, + { id: theirs.id }, + ); + expect(response.status).toBe(404); + }); +}); + +describe("notable days", () => { + it("marks a three-month high in the day, the index and the visit window", async () => { + const db = getPrismaClient(); + const owner = await makeUser("notable"); + const rows = []; + for (let i = 0; i < 150; i += 1) { + rows.push({ + userId: owner.id, + type: "WEIGHT" as const, + value: 80, + unit: "kg", + measuredAt: new Date(Date.UTC(2025, 10, 1, 7) + i * 86_400_000), + }); + } + await db.measurement.createMany({ data: rows }); + // 2026-03-31 (day 150) reads higher than every day before. + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 84, + unit: "kg", + measuredAt: new Date("2026-03-31T07:00:00.000Z"), + }, + }); + await db.encounter.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-02-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + await signIn(owner.id); + + const day = await json(await getDay("2026-03-31")); + expect(day.notable).toEqual([ + { + kind: "extremeHigh", + type: "WEIGHT", + params: { since: "2025-11-01", value: 84 }, + }, + ]); + expect(day.values[0].band).toMatchObject({ lo: expect.any(Number) }); + + const first = await json(await getDay("2025-11-01")); + expect(first.notable).toEqual([ + { kind: "firstValue", type: "WEIGHT", params: { type: "WEIGHT" } }, + ]); + + const { GET: index } = await import("@/app/api/day/index/route"); + const idx = await json<{ notable: string[] }>( + await call( + index as Handler, + "GET", + "/api/day/index?from=2026-03-01&to=2026-03-31", + ), + ); + expect(idx.notable).toEqual(["2026-03-31"]); + + const { GET: notable } = await import("@/app/api/day/notable/route"); + const window = await json<{ + from: string; + anchor: string; + observations: Array<{ date: string; kind: string }>; + }>(await call(notable as Handler, "GET", "/api/day/notable?to=2026-04-30")); + expect(window.anchor).toBe("lastVisit"); + expect(window.from).toBe("2026-02-01"); + expect(window.observations.map((o) => [o.date, o.kind])).toEqual([ + ["2026-03-31", "extremeHigh"], + ["2026-04-01", "gap"], + ]); + }); +}); + +describe("timeline", () => { + it("lays the record out, lists the standing items and rates the lanes", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tl", { timeline: true }); + const med = await db.medication.create({ + data: { userId: owner.id, name: "Metformin", dose: "500 mg" }, + }); + await db.medicationIntakeEvent.create({ + data: { + userId: owner.id, + medicationId: med.id, + scheduledFor: new Date("2026-01-05T07:00:00.000Z"), + takenAt: new Date("2026-01-05T07:00:00.000Z"), + }, + }); + await db.medication.create({ + data: { userId: owner.id, name: "Never taken", dose: "1" }, + }); + await db.allergy.create({ + data: { userId: owner.id, substance: "Penicillin" }, + }); + await db.illnessEpisode.create({ + data: { + userId: owner.id, + label: "Flu", + type: "INFECTION", + onsetAt: new Date("2026-02-01T08:00:00.000Z"), + resolvedAt: new Date("2026-02-08T08:00:00.000Z"), + }, + }); + await signIn(owner.id); + + const { GET } = await import("@/app/api/timeline/route"); + const timeline = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=year&to=2026-03-31", + ), + ); + const meds = timeline.lanes.find((l) => l.key === "medications"); + expect(meds?.items[0]).toMatchObject({ + label: "Metformin", + start: "2026-01-05", + startKnown: false, + open: true, + }); + expect( + timeline.lanes.find((l) => l.key === "illness")?.items[0], + ).toMatchObject({ + start: "2026-02-01", + end: "2026-02-08", + kind: "episode", + }); + expect(timeline.standing.map((s) => s.label).sort()).toEqual([ + "Never taken", + "Penicillin", + ]); + expect(timeline.range.dataFrom).toBe("2026-01-05"); + + const bad = await call(GET as Handler, "GET", "/api/timeline?values=NOPE"); + expect(bad.status).toBe(422); + + const { GET: readiness } = + await import("@/app/api/timeline/readiness/route"); + const inventory = await json< + import("@/lib/day/contract").TimelineReadinessResponse + >(await call(readiness as Handler, "GET", "/api/timeline/readiness")); + const byKey = Object.fromEntries(inventory.lanes.map((l) => [l.key, l])); + expect(byKey.medications.status).toBe("thin"); + expect(byKey.medications.gaps[0].href).toMatch( + /^\/medications\/.+\?edit=1$/, + ); + expect(byKey.illness.status).toBe("carries"); + expect(byKey.allergies).toMatchObject({ status: "thin", gaps: [] }); + expect(byKey.values.status).toBe("empty"); + expect(inventory.verdict).toBe("thin"); + }); +}); + +describe("performance smoke", () => { + it("loads a dense day in well under a second", async () => { + const db = getPrismaClient(); + const owner = await makeUser("perf"); + // Ninety days of minute-level pulse around the day, plus a daily weight. + const rows = []; + const start = Date.UTC(2026, 0, 1); + for (let d = 0; d < 90; d += 1) { + for (let m = 0; m < 24 * 60; m += 5) { + rows.push({ + userId: owner.id, + type: "PULSE" as const, + value: 60 + (m % 30), + unit: "bpm", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(start + d * 86_400_000 + m * 60_000), + }); + } + rows.push({ + userId: owner.id, + type: "WEIGHT" as const, + value: 80 + (d % 3), + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(start + d * 86_400_000 + 7 * 3_600_000), + }); + } + for (let i = 0; i < rows.length; i += 5000) { + await db.measurement.createMany({ data: rows.slice(i, i + 5000) }); + } + await db.$executeRawUnsafe(`ANALYZE "measurements"`); + await signIn(owner.id); + await getDay("2026-03-15"); + const t0 = performance.now(); + const response = await getDay("2026-03-15"); + const elapsed = performance.now() - t0; + expect(response.status).toBe(200); + const day = await json(response); + expect(day.values.find((v) => v.type === "PULSE")?.band).not.toBeNull(); + expect(elapsed).toBeLessThan(1000); + }); +}); diff --git a/tests/integration/mcp-output-schema-conformance.test.ts b/tests/integration/mcp-output-schema-conformance.test.ts index 5da41cbcb..508eb9cf3 100644 --- a/tests/integration/mcp-output-schema-conformance.test.ts +++ b/tests/integration/mcp-output-schema-conformance.test.ts @@ -42,6 +42,7 @@ import { import type { McpAuthContext } from "@/lib/mcp/auth"; import { sealLocation } from "@/lib/environment/location-cipher"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { getPrismaClient, truncateAllTables } from "./setup"; const DAY = 86_400_000; @@ -282,6 +283,23 @@ async function seedRecord(): Promise { }, }); } + // v1.42 — a life event on a day the record also holds readings on, with + // the timeline module on, so get_day reads a day where one exists and has + // to leave it out under the strict validator. + await prisma.user.update({ + where: { id }, + data: { modulePreferencesJson: { timeline: true } }, + }); + await prisma.lifeEvent.create({ + data: { + userId: id, + category: "LOSS", + startDate: isoDay(daysAgo(3)), + precision: "DAY", + titleEncrypted: encryptToBytes("Zqx-life-event"), + noteEncrypted: encryptToBytes("Zqx-life-note"), + }, + }); return id; } @@ -339,6 +357,7 @@ const CASES: Record>> = { get_illness_recovery: [{}], get_cycle: [{}], get_environment: [{}, { window: "last7days" }], + get_day: [{ date: isoDay(daysAgo(3)) }, { date: "2001-01-01" }], get_metrics: [ { metrics: ["weight", "bp", "steps"], window: "last30days" }, { metrics: ["hrv", "vo2_max"] }, @@ -481,6 +500,28 @@ describe("MCP tool outputs against their advertised schemas", () => { } }); + it("get_day answers with the day and never a life event", async () => { + const { client, close } = await connect(userId); + try { + const result = await client.callTool({ + name: "get_day", + arguments: { date: isoDay(daysAgo(3)) }, + }); + const out = result.structuredContent as { + present: boolean; + url?: string; + data?: { values: unknown[] }; + }; + expect(out.present).toBe(true); + expect(out.data!.values.length).toBeGreaterThan(0); + expect(out.url).toMatch(/\/\?day=\d{4}-\d{2}-\d{2}$/); + const text = JSON.stringify(out); + expect(text).not.toMatch(/Zqx|lifeEvent/); + } finally { + await close(); + } + }); + it("get_environment answers with data and no location anywhere", async () => { const { client, close } = await connect(userId); try { diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 34583f305..c76c669bc 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90); }); }); @@ -1875,6 +1875,89 @@ manageContract("DELETE /api/vaccinations/custom/[id]", { )?.deletedAt !== null, }); +/* v1.42 (#613) — life events: MANAGE on all three verbs. */ + +async function makeLifeEvent(userId: string) { + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + return getPrismaClient().lifeEvent.create({ + data: { + userId, + category: "HOME", + startDate: "2023-09-01", + precision: "MONTH", + titleEncrypted: encryptToBytes("Moved"), + }, + }); +} + +manageContract("POST /api/life-events", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/life-events" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/life-events/route"); + return call(POST as Handler, "POST", "/api/life-events", { + category: "WORK", + startDate: "2024-01-01", + precision: "YEAR", + title: "New job", + }); + }, + ok: 201, + auditAction: "life_event.create", + applied: async ({ ownerId }) => + (await getPrismaClient().lifeEvent.count({ + where: { userId: ownerId, category: "WORK" }, + })) === 1, +}); + +manageContract("PATCH /api/life-events/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/life-events/[id]" && entry.action === "PATCH", + )!, + prepare: (ownerId) => makeLifeEvent(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/life-events/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/life-events/${row.id}`, + { category: "FAMILY" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "life_event.update", + applied: async (row) => + (await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } })) + ?.category === "FAMILY", +}); + +manageContract("DELETE /api/life-events/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/life-events/[id]" && entry.action === "DELETE", + )!, + prepare: (ownerId) => makeLifeEvent(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/life-events/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/life-events/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "life_event.delete", + applied: async (row) => + (await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } })) + ?.deletedAt !== null, +}); + proveWriteRoute("POST /api/vaccinations/[id]/booster", { route: "/api/vaccinations/[id]/booster", // The dose the booster is planned against. `makeVaccination` records a @@ -2846,13 +2929,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(87); + expect(STRICT_DRIVER_KEYS.size).toBe(90); 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(87); + expect(REAL_EFFECT_KEYS.size).toBe(90); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); });