From aecf6f91b1e78bec98ca6e2aca4110f991f2c565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:16:22 +0200 Subject: [PATCH] fix(api): publish what the GET handlers send A response parsed against its own registered schema disagreed in thirteen operations. Where the handler was right, the schema now says so; where the handler had drifted from its siblings, it follows them. Schema: - measurements list and detail declare aggregationProvenance - series-batch and sleep night stages are partial records: only the requested types and the recorded stages are present - medications declare deliveryForm, the injection-site fields, externalId, externalSource, the low-stock bookkeeping, schedules[].doseWindows and nextDueScheduleId on the list - notification prefs declare coach.nudgeAiComposed and measurementReminder - mood insights correlations declare suppressed - record settings profile.unitPreference is nullable, as stored - insights/comprehensive describes the computed overview it returns rather than the model digest fields it never sends Handler: - GET /api/mood-entries/{id} shares the list projection and now carries tagKeys and ratedFactors - GET and PATCH /api/custom-metrics/{id} carry latest and entryCount - /api/analytics no longer leaks anomalyCount through the per-night sleep summary and the glucose meal-context split --- docs/api/openapi.yaml | 861 ++++++++++++++---- src/app/api/analytics/route.ts | 8 +- .../[id]/__tests__/route.test.ts | 69 +- src/app/api/custom-metrics/[id]/route.ts | 30 +- src/app/api/custom-metrics/route.ts | 15 +- .../mood-entries/[id]/__tests__/route.test.ts | 39 +- src/app/api/mood-entries/[id]/route.ts | 18 +- src/lib/analytics/__tests__/trends.test.ts | 17 + src/lib/analytics/summaries-slice.ts | 4 +- src/lib/analytics/trends.ts | 14 + src/lib/custom-metrics/custom-metric-store.ts | 17 + src/lib/mood/list-read.ts | 104 ++- src/lib/openapi/routes/insights/schemas.ts | 199 +++- src/lib/openapi/routes/measurements.ts | 19 +- src/lib/openapi/routes/medications/schemas.ts | 60 ++ src/lib/openapi/routes/mood.ts | 6 + src/lib/openapi/routes/profile.ts | 12 + src/lib/openapi/routes/record-settings.ts | 5 +- 18 files changed, 1214 insertions(+), 283 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89c03a66b..a913d754f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36009,6 +36009,13 @@ components: maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + doseWindows: + description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. + anyOf: + - type: array + items: + $ref: "#/components/schemas/DoseWindowEntry" + - type: "null" required: - id - medicationId @@ -46245,6 +46252,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -46684,85 +46698,7 @@ components: - value - measuredAt additionalProperties: false - required: - - WEIGHT - - BLOOD_PRESSURE_SYS - - BLOOD_PRESSURE_DIA - - PULSE - - BODY_FAT - - SLEEP_DURATION - - ACTIVITY_STEPS - - BLOOD_GLUCOSE - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - HEART_RATE_VARIABILITY - - RESTING_HEART_RATE - - ACTIVE_ENERGY_BURNED - - FLIGHTS_CLIMBED - - WALKING_RUNNING_DISTANCE - - VO2_MAX - - BODY_TEMPERATURE - - FAT_FREE_MASS - - FAT_MASS - - MUSCLE_MASS - - SKIN_TEMPERATURE - - PULSE_WAVE_VELOCITY - - VASCULAR_AGE - - VISCERAL_FAT - - AUDIO_EXPOSURE_ENV - - AUDIO_EXPOSURE_HEADPHONE - - TIME_IN_DAYLIGHT - - WALKING_STEADINESS - - AUDIO_EXPOSURE_EVENT - - RESPIRATORY_RATE - - BODY_MASS_INDEX - - LEAN_BODY_MASS - - WALKING_HEART_RATE_AVERAGE - - WALKING_ASYMMETRY - - WALKING_DOUBLE_SUPPORT - - WALKING_STEP_LENGTH - - WALKING_SPEED - - CARDIO_RECOVERY - - WRIST_TEMPERATURE - - FALL_COUNT - - SIX_MINUTE_WALK_DISTANCE - - STAIR_ASCENT_SPEED - - STAIR_DESCENT_SPEED - - BREATHING_DISTURBANCES - - IRREGULAR_RHYTHM_NOTIFICATION - - HIGH_HEART_RATE_EVENT - - LOW_HEART_RATE_EVENT - - WALKING_STEADINESS_EVENT - - BREATHING_DISTURBANCE_EVENT - - RECOVERY_SCORE - - STRESS_SCORE - - STRAIN_SCORE - - HRV_RMSSD - - DAY_STRAIN - - WORKOUT_STRAIN - - SLEEP_PERFORMANCE - - SLEEP_EFFICIENCY - - SLEEP_CONSISTENCY - - SLEEP_NEED - - ENERGY_EXPENDITURE_KJ - - AVERAGE_HEART_RATE - - MAX_HEART_RATE - - SLEEP_DISTURBANCE_COUNT - - ANS_CHARGE - - CARDIO_LOAD - - SLEEP_SCORE - - BODY_TEMPERATURE_DEVIATION - - RESILIENCE - - PHQ9_SCORE - - GAD7_SCORE - - GRIP_STRENGTH - - PAIN_NRS - - WAIST_CIRCUMFERENCE - - WAIST_TO_HEIGHT - - WHO5_SCORE - - SCI_SCORE - description: Per-type daily series, keyed by MeasurementType. + description: Per-type daily series, keyed by MeasurementType. Only the requested types are present. required: - series additionalProperties: false @@ -47086,6 +47022,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -47284,6 +47227,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -47730,13 +47680,8 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - required: - - IN_BED - - AWAKE - - ASLEEP - - REM - - CORE - - DEEP + description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it + was not measured. segments: type: array items: @@ -47884,13 +47829,8 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - required: - - IN_BED - - AWAKE - - ASLEEP - - REM - - CORE - - DEEP + description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it + was not measured. segments: type: array items: @@ -49882,12 +49822,68 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — + the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the + iOS client's local registry. + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer @@ -50062,15 +50058,11 @@ components: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended." - externalSource: - anyOf: - - type: string - const: APPLE_HEALTH - - type: "null" - description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ - HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — - the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the - iOS client's local registry. + nextDueScheduleId: + description: The schedule `nextDueAt` belongs to; null when nothing is due. + type: + - string + - "null" courseStatus: type: string enum: @@ -50136,6 +50128,7 @@ components: - name - dose - treatmentClass + - externalSource - dosesPerUnit - unitsPerDose - reorderLeadDays @@ -50166,7 +50159,7 @@ components: - courseCount - previousCourseEndedOn - canStartCourse - - externalSource + - nextDueScheduleId - courseStatus - intakeActionable - lastTakenAt @@ -50255,12 +50248,66 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer @@ -55202,12 +55249,26 @@ components: - weekly - biweekly description: "Nudge frequency cap: one per 7 or per 14 days." + nudgeAiComposed: + type: boolean + description: Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback. required: - nudgesEnabled - nudgeMedication - nudgeVitals - nudgeRoutine - nudgeFrequency + - nudgeAiComposed + additionalProperties: false + measurementReminder: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then + suppressed. Default false. + required: + - clientManaged additionalProperties: false content: type: object @@ -55234,6 +55295,7 @@ components: - mood - cycle - coach + - measurementReminder - content additionalProperties: false PatchNotificationPrefsResponse: @@ -61730,64 +61792,294 @@ components: InsightsComprehensiveResponse: type: object properties: - summary: - type: string - recommendations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - citations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - warnings: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - dailyBriefing: - anyOf: - - type: object - propertyNames: - type: string - additionalProperties: {} - - type: "null" - trendAnnotations: - anyOf: - - type: object - propertyNames: - type: string - additionalProperties: {} - - type: "null" - storyboardAnnotations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - metricSource: + summaries: type: object propertyNames: type: string - additionalProperties: {} + additionalProperties: + $ref: "#/components/schemas/ComprehensiveMetricSummary" + description: Keyed by `MeasurementType`. Only types with readings in the last 90 days are present. + bmi: + type: + - number + - "null" + bmiClassification: + anyOf: + - type: object + properties: + category: + type: string + color: + type: string + description: A CSS colour token for the web client. + severity: + type: string + enum: + - normal + - elevated + - warning + - danger + required: + - category + - color + - severity + additionalProperties: false + - type: "null" + bpClassification: + anyOf: + - type: object + properties: + category: + type: string + color: + type: string + description: A CSS colour token for the web client. + severity: + type: string + enum: + - normal + - elevated + - warning + - danger + required: + - category + - color + - severity + additionalProperties: false + - type: "null" + bpPctInTarget: + description: Share of blood-pressure readings inside the target band, 0-100. + type: + - number + - "null" + bpTargets: + anyOf: + - type: object + properties: + sysLow: + type: number + sysHigh: + type: number + diaLow: + type: number + diaHigh: + type: number + required: + - sysLow + - sysHigh + - diaLow + - diaHigh + additionalProperties: false + - type: "null" + description: Age-banded targets; null without a date of birth. + weightBpCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + scatterData: + type: array + items: + type: object + properties: + weight: + type: number + sysBP: + type: number + required: + - weight + - sysBP + additionalProperties: false + description: Weight against systolic pressure, paired by day. + bpMedicationCorrelation: + anyOf: + - type: object + properties: + r: + type: number + strength: + type: string + enum: + - stark + - moderat + - schwach + - keine + description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise + client-side. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + medicationCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - r + - strength + - n + - medicationCount + additionalProperties: false + - type: "null" + bpMedicationScatterData: + type: array + items: + type: object + properties: + continuityPct: + type: number + sysBP: + type: number + required: + - continuityPct + - sysBP + additionalProperties: false + moodSummary: + anyOf: + - $ref: "#/components/schemas/ComprehensiveMetricSummary" + - type: "null" + moodBpCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodBpScatterData: + type: array + items: + type: object + properties: + mood: + type: number + sysBP: + type: number + required: + - mood + - sysBP + additionalProperties: false + moodWeightCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodWeightScatterData: + type: array + items: + type: object + properties: + mood: + type: number + weight: + type: number + required: + - mood + - weight + additionalProperties: false + moodPulseCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodPulseScatterData: + type: array + items: + type: object + properties: + mood: + type: number + pulse: + type: number + required: + - mood + - pulse + additionalProperties: false + medications: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + dose: + type: string + category: + type: string + categoryLabel: + description: The label of a custom category; null for a built-in one. + type: + - string + - "null" + compliance7: + type: number + description: Percent, 0-100. + compliance30: + type: number + description: Percent, 0-100. + streak: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + taken7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skipped7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + missed7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - name + - dose + - category + - categoryLabel + - compliance7 + - compliance30 + - streak + - taken7 + - skipped7 + - missed7 + additionalProperties: false + description: Cadence-aware compliance per active medication. + alerts: + type: array + items: + type: object + properties: + level: + type: string + enum: + - success + - info + - warning + - danger + title: + type: string + message: + type: string + required: + - level + - title + - message + additionalProperties: false + hasProvider: + type: boolean + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never + means the operator switched AI off or consent is missing; `ai` says that." + dataSpanDays: + type: integer + minimum: 0 + maximum: 9007199254740991 + totalMeasurements: + type: integer + minimum: 0 + maximum: 9007199254740991 revalidating: + type: boolean description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body. - type: boolean - hasProvider: - description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never - means the operator switched AI off or consent is missing; `ai` says that." - type: boolean ai: type: object properties: @@ -61803,14 +62095,163 @@ components: notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say." required: - - summary - - recommendations - - citations - - warnings + - summaries + - bmi + - bmiClassification + - bpClassification + - bpPctInTarget + - bpTargets + - weightBpCorrelation + - scatterData + - bpMedicationCorrelation + - bpMedicationScatterData + - moodSummary + - moodBpCorrelation + - moodBpScatterData + - moodWeightCorrelation + - moodWeightScatterData + - moodPulseCorrelation + - moodPulseScatterData + - medications + - alerts + - hasProvider + - dataSpanDays + - totalMeasurements + - revalidating - ai additionalProperties: false description: The Insights overview's computed read. No provider is called on this path; every figure is computed from - the record. + the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`. + ComprehensiveMetricSummary: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + latest: + type: + - number + - "null" + min: + type: + - number + - "null" + max: + type: + - number + - "null" + mean: + type: + - number + - "null" + median: + type: + - number + - "null" + avg7: + type: + - number + - "null" + avg30: + type: + - number + - "null" + slope7: + anyOf: + - type: object + properties: + slope: + type: number + description: Least-squares slope in units per day. + direction: + type: string + enum: + - up + - down + - stable + confidence: + type: number + description: R² of the fit, 0..1. + required: + - slope + - direction + - confidence + additionalProperties: false + - type: "null" + slope30: + anyOf: + - type: object + properties: + slope: + type: number + description: Least-squares slope in units per day. + direction: + type: string + enum: + - up + - down + - stable + confidence: + type: number + description: R² of the fit, 0..1. + required: + - slope + - direction + - confidence + additionalProperties: false + - type: "null" + anomalyCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the series the outlier rule flags. + avg30LastMonth: + type: + - number + - "null" + avg30LastYear: + type: + - number + - "null" + required: + - count + - latest + - min + - max + - mean + - median + - avg7 + - avg30 + - slope7 + - slope30 + - anomalyCount + additionalProperties: false + CorrelationStat: + type: object + properties: + r: + type: number + strength: + type: string + enum: + - stark + - moderat + - schwach + - keine + description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise + client-side. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - r + - strength + - n + additionalProperties: false + description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not + significant. InsightsCardsEnvelope: type: object properties: @@ -66752,6 +67193,13 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Paired-day count. + suppressed: + description: "Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when + `result` is set." + type: string + enum: + - insufficientPairs + - notSignificant required: - result - points @@ -82886,10 +83334,13 @@ components: timezone: type: string unitPreference: - type: string - enum: - - metric - - imperial + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + description: "Null when the record never chose one: metric applies." timeFormat: type: string enum: @@ -87747,6 +88198,13 @@ components: maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + doseWindows: + description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. + anyOf: + - type: array + items: + $ref: "#/components/schemas/DoseWindowEntryOutput" + - type: "null" required: - id - medicationId @@ -87770,6 +88228,29 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + DoseWindowEntryOutput: + type: object + properties: + timeOfDay: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: The dose time this window applies to (matches a `timesOfDay` entry). + start: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band lower bound (HH:mm, user local). + end: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band upper bound (HH:mm, user local). Must be >= `start`. + required: + - timeOfDay + - start + - end + additionalProperties: false + description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` + (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then + ad-hoc. DisableCoachFlagOutput: type: object properties: @@ -87830,12 +88311,66 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index fff6a3129..2cdc57ea3 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { analyticsQuerySchema } from "@/lib/validations/analytics"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends"; import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; @@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { glucoseRows, (r) => r.glucoseContext, )) { - glucoseByContext[bucket] = summarize( - ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + glucoseByContext[bucket] = wireSummary( + summarize( + ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + ), ); } diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 17c2aae11..bc70c9d62 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -53,6 +53,22 @@ const EXISTING = { deletedAt: null, }; +/** + * The row as the reads load it: with the newest live entry and the live entry + * count joined on, which the published row carries as `latest` and + * `entryCount`. + */ +const JOINS = { + entries: [ + { + value: 42.5, + unit: "kg", + measuredAt: new Date("2026-06-03T07:00:00.000Z"), + }, + ], + _count: { entries: 7 }, +}; + const params = (id: string) => ({ params: Promise.resolve({ id }) }); function patchReq(body: unknown): NextRequest { @@ -77,17 +93,54 @@ beforeEach(() => { describe("GET /api/custom-metrics/[id]", () => { it("returns the metric for its owner", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( - EXISTING as never, - ); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + ...JOINS, + } as never); const res = await GET( new NextRequest("http://localhost/api/custom-metrics/cm-1"), params("cm-1"), ); expect(res.status).toBe(200); - const body = (await res.json()) as { data: { name: string } | null }; + const body = (await res.json()) as { + data: { + name: string; + latest: { value: number; measuredAt: string } | null; + entryCount: number; + } | null; + }; expect(body.data?.name).toBe("Grip strength"); + // The published row carries the latest value and the count on its own, + // as it does in the catalogue; the read asks for both joins. + expect(body.data?.latest).toEqual({ + value: 42.5, + unit: "kg", + measuredAt: "2026-06-03T07:00:00.000Z", + }); + expect(body.data?.entryCount).toBe(7); + const findCall = vi.mocked(prisma.customMetric.findFirst).mock + .calls[0][0] as { include?: Record }; + expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]); + }); + + it("answers latest null and a zero count for a metric with no entries", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + entries: [], + _count: { entries: 0 }, + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/custom-metrics/cm-1"), + params("cm-1"), + ); + const body = (await res.json()) as { + data: { latest: unknown; entryCount: number }; + }; + expect(body.data.latest).toBeNull(); + expect(body.data.entryCount).toBe(0); }); it("404s a cross-user metric", async () => { @@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { ); vi.mocked(prisma.customMetric.update).mockResolvedValue({ ...EXISTING, + ...JOINS, targetLow: 20, targetHigh: 50, } as never); @@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => { .calls[0][0] as { data: Record }; expect(updateCall.data.targetLow).toBe(20); expect(updateCall.data.targetHigh).toBe(50); + // The edit echo is the same published row as the read. + const body = (await res.json()) as { + data: { entryCount: number; latest: { value: number } | null }; + }; + expect(body.data.entryCount).toBe(7); + expect(body.data.latest?.value).toBe(42.5); }); it("persists only the explicit correlation opt-in field", async () => { @@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { ); vi.mocked(prisma.customMetric.update).mockResolvedValue({ ...EXISTING, + ...JOINS, correlationEnabled: true, } as never); diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index de09d2910..6ba09b4c0 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -11,7 +11,10 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; -import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store"; +import { + CUSTOM_METRIC_LATEST_INCLUDE, + serialiseCustomMetricWithLatest, +} from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics"; @@ -41,8 +44,11 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; + // The same joins as the list read: the published row carries the latest + // value and the entry count, on its own as in the catalogue. const row = await prisma.customMetric.findFirst({ where: { id, deletedAt: null }, + include: CUSTOM_METRIC_LATEST_INCLUDE, }); if (!row || row.userId !== user.id) { return apiError("Custom metric not found", 404); @@ -53,7 +59,13 @@ export const GET = apiHandler( meta: { customMetricId: id }, }); - return apiSuccess(serialiseCustomMetric(row)); + return apiSuccess( + serialiseCustomMetricWithLatest({ + ...row, + latest: row.entries[0] ?? null, + entryCount: row._count.entries, + }), + ); }, ); @@ -135,7 +147,11 @@ export const PATCH = apiHandler( return apiError("targetLow must not exceed targetHigh", 422); } - const updated = await prisma.customMetric.update({ where: { id }, data }); + const updated = await prisma.customMetric.update({ + where: { id }, + data, + include: CUSTOM_METRIC_LATEST_INCLUDE, + }); await auditLog("customMetric.update", { userId: user.id, @@ -149,7 +165,13 @@ export const PATCH = apiHandler( }); invalidateUserCorrelationPatterns(user.id); - return apiSuccess(serialiseCustomMetric(updated)); + return apiSuccess( + serialiseCustomMetricWithLatest({ + ...updated, + latest: updated.entries[0] ?? null, + entryCount: updated._count.entries, + }), + ); }, ); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index d4d98b477..3dd654c55 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -11,7 +11,10 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; -import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store"; +import { + CUSTOM_METRIC_LATEST_INCLUDE, + serialiseCustomMetricWithLatest, +} from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; @@ -34,15 +37,7 @@ export const GET = apiHandler(async () => { const rows = await prisma.customMetric.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { name: "asc" }, - include: { - entries: { - where: { deletedAt: null }, - orderBy: { measuredAt: "desc" }, - take: 1, - select: { value: true, unit: true, measuredAt: true }, - }, - _count: { select: { entries: { where: { deletedAt: null } } } }, - }, + include: CUSTOM_METRIC_LATEST_INCLUDE, }); annotate({ diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index 1b96a0f40..19727e41f 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -68,7 +68,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { GET, PUT } from "../route"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; @@ -138,6 +138,43 @@ beforeEach(() => { ); }); +describe("GET /api/mood-entries/[id] — the list's wire shape", () => { + it("carries the structured tags and rated factors, as the list does", async () => { + vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({ + ...EXISTING_ENTRY, + tagLinks: [ + { rating: null, moodTag: { key: "sleep_good", kind: "BINARY" } }, + { rating: 4, moodTag: { key: "energy", kind: "RATED" } }, + ], + context: null, + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/mood-entries/me1"), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + tagLinks?: unknown; + }; + }; + expect(body.data.tagKeys).toEqual(["sleep_good"]); + expect(body.data.ratedFactors).toEqual([{ key: "energy", rating: 4 }]); + // The join is projected, never shipped raw. + expect(body.data.tagLinks).toBeUndefined(); + const findCall = vi.mocked(prisma.moodEntry.findFirst).mock.calls[0][0] as { + include?: Record; + }; + expect(Object.keys(findCall.include ?? {}).sort()).toEqual([ + "context", + "tagLinks", + ]); + }); +}); + describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { // Bad `mood` enum + bad `moodLoggedAt`. diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index bfb537a1e..7730d4d01 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,10 +15,11 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey } from "@/lib/mood/date-key"; +import { MOOD_ENTRY_WIRE_INCLUDE, toMoodEntryWire } from "@/lib/mood/list-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; -import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -50,9 +51,9 @@ export const GET = apiHandler( // (not `findUnique`) because `deletedAt` is not part of a unique index. const entry = await prisma.moodEntry.findFirst({ where: { id, deletedAt: null }, - // v1.38 — the day context rides with the entry rather than behind a - // second request: the edit surface needs both or neither. - include: { context: true }, + // The same joins and the same projection as the list read, so an + // entry opened on its own carries `tagKeys` and `ratedFactors` too. + include: MOOD_ENTRY_WIRE_INCLUDE, }); if (!entry || entry.userId !== user.id) { @@ -64,14 +65,7 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - const { context, ...row } = entry; - return apiSuccess({ - // v1.37 — level-A values under the keys the write path takes. - ...shapeLevelA(shapeMoodNote(row)), - tags: parseTags(row.tags), - // v1.38 — lists decoded, note decrypted, ciphertext dropped. - context: context ? contextForWire(context) : null, - }); + return apiSuccess(toMoodEntryWire(entry)); }, ); diff --git a/src/lib/analytics/__tests__/trends.test.ts b/src/lib/analytics/__tests__/trends.test.ts index 031f43431..d9242f3a5 100644 --- a/src/lib/analytics/__tests__/trends.test.ts +++ b/src/lib/analytics/__tests__/trends.test.ts @@ -3,6 +3,7 @@ import { trendSlope, detectAnomalies, summarize, + wireSummary, type DataPoint, } from "../trends"; @@ -166,3 +167,19 @@ describe("summarize", () => { expect(summary.latest).toBe(data[N - 1].value); }); }); + +describe("wireSummary", () => { + it("drops the in-process anomaly count and keeps every published statistic", () => { + const data = Array.from({ length: 10 }, (_, i) => ({ + date: new Date(Date.UTC(2026, 0, i + 1)), + value: i === 9 ? 400 : 70 + i, + })); + const full = summarize(data); + expect(full).toHaveProperty("anomalyCount"); + + const wire = wireSummary(full); + expect(wire).not.toHaveProperty("anomalyCount"); + const { anomalyCount: _dropped, ...rest } = full; + expect(wire).toEqual(rest); + }); +}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 2d43ab91f..26b0b15ad 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -74,7 +74,7 @@ import { isHourlyMeanTypeSql, windowMeanSql, } from "@/lib/measurements/day-mean"; -import type { DataSummary } from "@/lib/analytics/trends"; +import { wireSummary, type DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; import { caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -491,7 +491,7 @@ async function withSleepNightTotals( // dashboard tile reads slope30); recompute them off the night series is // out of scope here — the per-night `summarize()` already fills slope7 / // slope30 from the night DataPoints, so use them directly. - slice.summaries.SLEEP_DURATION = summary; + slice.summaries.SLEEP_DURATION = wireSummary(summary); // Additive, observational: the latest night's main session saw two // writer buckets with clearly different asleep totals. Round to whole // minutes, matching the `/api/sleep/night` serialisation. diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index 4e3d18327..71fbda8be 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -156,6 +156,20 @@ export interface DataSummary { avg30LastYear?: number | null; } +/** + * A summary as `/api/analytics` publishes it: without `anomalyCount`. + * + * The SQL summaries path cannot compute the figure, so the route's published + * summary has not carried it since v1.37.19. Two of its summaries are still + * built by `summarize()` (the per-night sleep series and the glucose + * meal-context split), and both put it back on the wire until they were run + * through this. + */ +export function wireSummary(summary: DataSummary): DataSummary { + const { anomalyCount: _inProcessOnly, ...wire } = summary; + return wire; +} + export function summarize(data: DataPoint[]): DataSummary { if (data.length === 0) { // Empty series — return null for stats so callers can render an explicit diff --git a/src/lib/custom-metrics/custom-metric-store.ts b/src/lib/custom-metrics/custom-metric-store.ts index d2352f4be..3caf2a563 100644 --- a/src/lib/custom-metrics/custom-metric-store.ts +++ b/src/lib/custom-metrics/custom-metric-store.ts @@ -12,8 +12,25 @@ * no rollup, no sync, no FHIR, no insights. Charts read entries LIVE. */ +import type { Prisma } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; +/** + * The joins behind a published custom-metric row: its newest live entry and + * its live entry count. Every read that answers with the row — the + * catalogue, the single metric, the edit echo — includes these, so `latest` + * and `entryCount` cannot be present on one and missing on another. + */ +export const CUSTOM_METRIC_LATEST_INCLUDE = { + entries: { + where: { deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 1, + select: { value: true, unit: true, measuredAt: true }, + }, + _count: { select: { entries: { where: { deletedAt: null } } } }, +} satisfies Prisma.CustomMetricInclude; + /** A custom-metric catalog row as the API serialises it. */ export interface CustomMetricRow { id: string; diff --git a/src/lib/mood/list-read.ts b/src/lib/mood/list-read.ts index 89dd947b3..f72966d65 100644 --- a/src/lib/mood/list-read.ts +++ b/src/lib/mood/list-read.ts @@ -5,6 +5,7 @@ * client `queryFn` gets from the wire. Ciphertext never leaves this module: * the note is decrypted onto `note` and the context rides `contextForWire`. */ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { shapeMoodNote } from "@/lib/crypto/note-cipher"; import { shapeLevelA } from "@/lib/mood/level-a"; @@ -30,6 +31,66 @@ function parseTags(tags: string | null): string[] { } } +/** + * What a read of one entry joins: the structured-tag links with their rating + * and kind, and the day context. + * + * v1.8.5 — the link keys pre-populate the taxonomy picker; v1.12.0 — the + * per-link `rating` + tag `kind` split binary tags from rated factors on + * hydrate; v1.38 — the day context, sparse by design: most rows carry none + * and the join costs nothing on those. + */ +export const MOOD_ENTRY_WIRE_INCLUDE = { + tagLinks: { + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, + }, + context: true, +} satisfies Prisma.MoodEntryInclude; + +type MoodEntryWithWireJoins = Prisma.MoodEntryGetPayload<{ + include: typeof MOOD_ENTRY_WIRE_INCLUDE; +}>; + +/** + * The one wire shape of a mood entry, for the list and the single-entry + * read alike, so an entry opened on its own carries the same fields as the + * same entry in the list. + */ +export function toMoodEntryWire({ + tagLinks, + context, + ...e +}: MoodEntryWithWireJoins) { + return { + // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. + // v1.37 — the five level-A values under the keys the create path takes, + // so the edit form reads back what it wrote instead of mapping column + // names to wire names by hand. The column spelling is stripped, not + // joined: one number under two names is a choice nobody made. + ...shapeLevelA(shapeMoodNote(e)), + tags: parseTags(e.tags), + // v1.8.5 — flat list of binary structured-tag keys attached to the + // entry (rated factors are surfaced separately below). + tagKeys: tagLinks + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + // v1.12.0 — rated factors with their per-entry score, so the edit + // form re-renders the sliders without a refetch. + ratedFactors: tagLinks + .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), + // v1.38 — the day context, or null. The ciphertext never leaves the + // server: `contextForWire` reads the note and drops the column. + context: context ? contextForWire(context) : null, + }; +} + export async function listMoodEntriesPage( userId: string, params: MoodEntryListParams, @@ -59,51 +120,12 @@ export async function listMoodEntriesPage( orderBy: { [sortBy]: sortDir }, take: limit, skip: offset, - // v1.8.5 — include the structured-tag link keys so the edit form - // can pre-populate the taxonomy picker. - // v1.12.0 — also carry the per-link `rating` + the tag `kind` so - // the client can split binary tags from rated factors on hydrate. - include: { - tagLinks: { - select: { - rating: true, - moodTag: { select: { key: true, kind: true } }, - }, - }, - // v1.38 — the day context, so the edit dialog can pre-populate the - // sections without a second round trip per row. Sparse by design: most - // rows carry none and the join costs nothing on those. - context: true, - }, + include: MOOD_ENTRY_WIRE_INCLUDE, }), prisma.moodEntry.count({ where }), ]); - const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({ - // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - // v1.37 — the five level-A values under the keys the create path takes, - // so the edit form reads back what it wrote instead of mapping column - // names to wire names by hand. The column spelling is stripped, not - // joined: one number under two names is a choice nobody made. - ...shapeLevelA(shapeMoodNote(e)), - tags: parseTags(e.tags), - // v1.8.5 — flat list of binary structured-tag keys attached to the - // entry (rated factors are surfaced separately below). - tagKeys: tagLinks - .filter((link) => link.moodTag.kind !== "RATED") - .map((link) => link.moodTag.key), - // v1.12.0 — rated factors with their per-entry score, so the edit - // form re-renders the sliders without a refetch. - ratedFactors: tagLinks - .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) - .map((link) => ({ - key: link.moodTag.key, - rating: link.rating as number, - })), - // v1.38 — the day context, or null. The ciphertext never leaves the - // server: `contextForWire` reads the note and drops the column. - context: context ? contextForWire(context) : null, - })); + const entriesWithParsedTags = entries.map(toMoodEntryWire); return { entries: entriesWithParsedTags, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 5bcddf09e..615b428cd 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -32,45 +32,6 @@ import { isoDateTime } from "@/lib/validations/datetime"; /** The retrospective periods the narrative route accepts, read off the engine. */ const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS); -export const insightsComprehensiveResponse = z - .object({ - summary: z.string(), - recommendations: z.array(z.record(z.string(), z.unknown())), - citations: z.array(z.record(z.string(), z.unknown())), - warnings: z.array(z.record(z.string(), z.unknown())), - dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), - trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), - storyboardAnnotations: z - .array(z.record(z.string(), z.unknown())) - .optional(), - metricSource: z.record(z.string(), z.unknown()).optional(), - revalidating: z - .boolean() - .optional() - .describe( - "True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.", - ), - hasProvider: z - .boolean() - .optional() - .describe( - "Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.", - ), - ai: z - .object({ - briefing: aiCapabilityState, - statusText: aiCapabilityState, - }) - .describe( - "Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.", - ), - }) - .meta({ - id: "InsightsComprehensiveResponse", - description: - "The Insights overview's computed read. No provider is called on this path; every figure is computed from the record.", - }); - // v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is // derived from the same registry the route validates against, so the // spec, the route, and the cache scope cannot drift. The seven @@ -1999,6 +1960,166 @@ export const dataSummary = z "Per-measurement-type summary. Every statistic is null on an empty series rather than 0, so a client never charts a zero it was never given. `anomalyCount` and `slope90` were computed here historically and left this wire in v1.37.19 — they are not fields of this shape.", }); +/** + * A summary the overview computes in-process. The same statistics as the + * analytics summary, plus the count of readings the outlier rule flags, which + * only the in-process path can produce. Declared here rather than derived from + * either `DataSummary` copy so its trend component cannot collide with theirs. + */ +const comprehensiveTrend = z.object({ + slope: z.number().describe("Least-squares slope in units per day."), + direction: z.enum(["up", "down", "stable"]), + confidence: z.number().describe("R² of the fit, 0..1."), +}); + +const comprehensiveSummary = z + .object({ + count: z.number().int(), + latest: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + mean: z.number().nullable(), + median: z.number().nullable(), + avg7: z.number().nullable(), + avg30: z.number().nullable(), + slope7: comprehensiveTrend.nullable(), + slope30: comprehensiveTrend.nullable(), + anomalyCount: z + .number() + .int() + .nonnegative() + .describe("Readings in the series the outlier rule flags."), + avg30LastMonth: z.number().nullable().optional(), + avg30LastYear: z.number().nullable().optional(), + }) + .meta({ id: "ComprehensiveMetricSummary" }); + +const correlationStat = z + .object({ + r: z.number(), + strength: z + .enum(["stark", "moderat", "schwach", "keine"]) + .describe( + "Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side.", + ), + n: z.number().int(), + }) + .meta({ + id: "CorrelationStat", + description: + "A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant.", + }); + +const classification = z.object({ + category: z.string(), + color: z.string().describe("A CSS colour token for the web client."), + severity: z.enum(["normal", "elevated", "warning", "danger"]), +}); + +export const insightsComprehensiveResponse = z + .object({ + summaries: z + .record(z.string(), comprehensiveSummary) + .describe( + "Keyed by `MeasurementType`. Only types with readings in the last 90 days are present.", + ), + bmi: z.number().nullable(), + bmiClassification: classification.nullable(), + bpClassification: classification.nullable(), + bpPctInTarget: z + .number() + .nullable() + .describe( + "Share of blood-pressure readings inside the target band, 0-100.", + ), + bpTargets: z + .object({ + sysLow: z.number(), + sysHigh: z.number(), + diaLow: z.number(), + diaHigh: z.number(), + }) + .nullable() + .describe("Age-banded targets; null without a date of birth."), + weightBpCorrelation: correlationStat.nullable(), + scatterData: z + .array(z.object({ weight: z.number(), sysBP: z.number() })) + .describe("Weight against systolic pressure, paired by day."), + bpMedicationCorrelation: correlationStat + .extend({ medicationCount: z.number().int() }) + .nullable(), + bpMedicationScatterData: z.array( + z.object({ continuityPct: z.number(), sysBP: z.number() }), + ), + moodSummary: comprehensiveSummary.nullable(), + moodBpCorrelation: correlationStat.nullable(), + moodBpScatterData: z.array( + z.object({ mood: z.number(), sysBP: z.number() }), + ), + moodWeightCorrelation: correlationStat.nullable(), + moodWeightScatterData: z.array( + z.object({ mood: z.number(), weight: z.number() }), + ), + moodPulseCorrelation: correlationStat.nullable(), + moodPulseScatterData: z.array( + z.object({ mood: z.number(), pulse: z.number() }), + ), + medications: z + .array( + z.object({ + id: z.string(), + name: z.string(), + dose: z.string(), + category: z.string(), + categoryLabel: z + .string() + .nullable() + .describe( + "The label of a custom category; null for a built-in one.", + ), + compliance7: z.number().describe("Percent, 0-100."), + compliance30: z.number().describe("Percent, 0-100."), + streak: z.number().int(), + taken7: z.number().int(), + skipped7: z.number().int(), + missed7: z.number().int(), + }), + ) + .describe("Cadence-aware compliance per active medication."), + alerts: z.array( + z.object({ + level: z.enum(["success", "info", "warning", "danger"]), + title: z.string(), + message: z.string(), + }), + ), + hasProvider: z + .boolean() + .describe( + "Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.", + ), + dataSpanDays: z.number().int().nonnegative(), + totalMeasurements: z.number().int().nonnegative(), + revalidating: z + .boolean() + .describe( + "True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.", + ), + ai: z + .object({ + briefing: aiCapabilityState, + statusText: aiCapabilityState, + }) + .describe( + "Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.", + ), + }) + .meta({ + id: "InsightsComprehensiveResponse", + description: + "The Insights overview's computed read. No provider is called on this path; every figure is computed from the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`.", + }); + const lastSeenSlot = z .object({ lastSeenAt: isoDateTime(), diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index e93227d1f..aff6fe3be 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -290,6 +290,13 @@ export const measurementResource = z .optional() .describe("External-system dedup key; null for manual entries."), externalSourceVersion: z.string().nullable().optional(), + aggregationProvenance: z + .string() + .nullable() + .optional() + .describe( + "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently.", + ), glucoseContext: z .string() .nullable() @@ -450,7 +457,11 @@ const sleepSessionResource = z.object({ .describe( "True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).", ), - stages: z.record(sleepStageEnum, z.number().int().nonnegative()), + stages: z + .partialRecord(sleepStageEnum, z.number().int().nonnegative()) + .describe( + "Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured.", + ), segments: z.array(sleepSegmentResource), sourceDiscrepancy: z .object({ @@ -757,8 +768,10 @@ const seriesBatchRowSchema = z.object({ const seriesBatchResponse = z .object({ series: z - .record(measurementTypeEnum, z.array(seriesBatchRowSchema)) - .describe("Per-type daily series, keyed by MeasurementType."), + .partialRecord(measurementTypeEnum, z.array(seriesBatchRowSchema)) + .describe( + "Per-type daily series, keyed by MeasurementType. Only the requested types are present.", + ), }) .meta({ id: "MeasurementsSeriesBatchResponse", diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 96705ee7b..ecc2d42e1 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -13,6 +13,8 @@ import { MEDICATION_CATEGORY_VALUES, MEDICATION_CONTAINER_TYPE_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, + doseWindowEntrySchema, + injectionSiteEnum, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; import { @@ -223,6 +225,13 @@ export const medicationScheduleResource = z .describe( 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', ), + doseWindows: z + .array(doseWindowEntrySchema) + .nullable() + .optional() + .describe( + "Per-dose on-time windows as stored. Null when every dose time keeps the default derivation.", + ), }) .meta({ id: "MedicationSchedule", @@ -233,9 +242,54 @@ export const medicationScheduleResource = z export const medicationResource = z .object({ id: z.string(), + userId: z.string().optional().describe("The record the row belongs to."), name: z.string(), dose: z.string(), treatmentClass: medicationTreatmentClassEnum, + deliveryForm: z + .enum(["ORAL", "INJECTION", "OTHER"]) + .optional() + .describe( + "How the medication is taken. A one-time injection is `oneShot` with `INJECTION`.", + ), + trackInjectionSites: z + .boolean() + .optional() + .describe( + "Whether intakes record an injection site. Only offered for `INJECTION`.", + ), + allowedInjectionSites: z + .array(injectionSiteEnum) + .optional() + .describe("The sites the rotation offers; empty means every site."), + externalSource: z + .literal("APPLE_HEALTH") + .nullable() + .optional() + .describe( + "v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication.", + ), + externalId: z + .string() + .nullable() + .optional() + .describe( + "The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one.", + ), + lowStockNotifiedAt: isoDateTime() + .nullable() + .optional() + .describe( + "When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written.", + ), + lowStockNotifiedThresholdDays: z + .number() + .int() + .nullable() + .optional() + .describe( + "The runway threshold, in days, that alert fired at. Server-written.", + ), dosesPerUnit: z .number() .int() @@ -371,6 +425,12 @@ export const medicationListEntry = medicationResource // even though the shared base leaves them optional for the write paths. nextDueAt: isoDateTime().nullable(), nextDueOverdue: z.boolean(), + nextDueScheduleId: z + .string() + .nullable() + .describe( + "The schedule `nextDueAt` belongs to; null when nothing is due.", + ), remindersActive: z .boolean() .describe( diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index d352740a3..f543d90b6 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -1192,6 +1192,12 @@ const moodAggregatesResponse = z .array(z.object({ x: z.number(), y: z.number() })) .describe("Mood (x) versus metric (y), paired by day."), n: z.number().int().describe("Paired-day count."), + suppressed: z + .enum(["insufficientPairs", "notSignificant"]) + .optional() + .describe( + "Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set.", + ), }), ) .describe( diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 8c55acee5..f8a930715 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -383,6 +383,18 @@ const notificationPrefsResolved = z nudgeFrequency: z .enum(["weekly", "biweekly"]) .describe("Nudge frequency cap: one per 7 or per 14 days."), + nudgeAiComposed: z + .boolean() + .describe( + "Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback.", + ), + }), + measurementReminder: z.object({ + clientManaged: z + .boolean() + .describe( + "True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then suppressed. Default false.", + ), }), content: z.object({ detail: z diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index 755506119..d332d79e3 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -128,7 +128,10 @@ const profileSettings = z gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(), timezone: z.string(), - unitPreference: z.enum(["metric", "imperial"]), + unitPreference: z + .enum(["metric", "imperial"]) + .nullable() + .describe("Null when the record never chose one: metric applies."), timeFormat: z.enum(["AUTO", "H12", "H24"]), dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]), })