diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bcf344eb7..e16f5819f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5042,6 +5042,394 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 + /api/activity/calendar: + get: + tags: + - Measurements + summary: Activity calendar against the person's own normal + description: "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step + total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and + workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. + Each day is placed against the person's own spread over the 84 days before the period; the period total against + the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population + norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts + module." + parameters: + - in: query + name: period + schema: + default: month + description: "Calendar span: one week (Monday to Sunday) or one month." + type: string + enum: + - week + - month + description: "Calendar span: one week (Monday to Sunday) or one month." + - in: query + name: date + schema: + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + responses: + "200": + description: The calendar. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityCalendarEnvelope" + "401": *a1 + "403": *a13 + "422": *a4 + "429": *a5 + /api/activity/goals: + get: + tags: + - Measurements + summary: Activity goals + description: "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = + not set; there is no default. Owner only; a delegate reads the goals inside the calendar." + responses: + "200": + description: The goals. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Measurements + summary: Set activity goals + description: "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated + session cannot change another person's goals." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatch" + responses: + "200": + description: The goals after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" + "401": *a1 + "422": *a4 + "429": *a12 + /api/measurements/provenance: + get: + tags: + - Measurements + summary: Which source a metric's day value comes from + description: "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative + metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, + and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups + and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and + no pick: every source counts there. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: date + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + required: true + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + responses: + "200": + description: The day's sources and the pick. + content: + application/json: + schema: + $ref: "#/components/schemas/DayProvenanceEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/measurements/provenance/compare: + get: + tags: + - Measurements + summary: Two sources of one metric side by side + description: "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the + ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. + Without `a` / `b` the two sources covering the most days are compared. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: from + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First UTC day, inclusive. + required: true + description: First UTC day, inclusive. + - in: query + name: to + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Last UTC day, inclusive. + required: true + description: Last UTC day, inclusive. + - in: query + name: a + schema: + description: First source. Defaults to the source covering the most days. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: First source. Defaults to the source covering the most days. + - in: query + name: b + schema: + description: Second source. Defaults to the next one. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: Second source. Defaults to the next one. + responses: + "200": + description: The comparison. + content: + application/json: + schema: + $ref: "#/components/schemas/SourceComparisonEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/devices: post: tags: @@ -14958,6 +15346,400 @@ paths: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a4 "429": *a5 + /api/import/labs-csv: + post: + tags: + - Import + summary: Import lab results from CSV + description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the + separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its + own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is + refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the + whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 + checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with + `module.disabled` when the module is switched off. Each row's name resolves to the account's marker the way a + scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A + numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed + value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a + qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." + parameters: + - in: query + name: dryRun + schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" + type: string + enum: + - "0" + - "1" + - "true" + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." + - in: query + name: header + schema: + description: "`1` when the first row holds column names and is not data." + default: "1" + type: string + enum: + - "0" + - "1" + description: "`1` when the first row holds column names and is not data." + - in: query + name: delimiter + schema: + default: auto + description: Column separator; `auto` detects it from the first line. + type: string + enum: + - auto + - comma + - semicolon + - tab + description: Column separator; `auto` detects it from the first line. + - in: query + name: decimal + schema: + default: dot + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + type: string + enum: + - dot + - comma + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + - in: query + name: dateOrder + schema: + default: iso + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + type: string + enum: + - iso + - dmy + - mdy + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + - in: query + name: timezone + schema: + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + type: string + maxLength: 64 + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + - in: query + name: analyte + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the marker name (required). + required: true + description: Column of the marker name (required). + - in: query + name: value + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept + as a qualitative result. + required: true + description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept + as a qualitative result. + - in: query + name: unit + schema: + description: Column of the unit. Required for a numeric result. + type: integer + minimum: 0 + maximum: 99 + description: Column of the unit. Required for a numeric result. + - in: query + name: date + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the date, or date and time (required). + required: true + description: Column of the date, or date and time (required). + - in: query + name: time + schema: + description: Column of the time, when separate. + type: integer + minimum: 0 + maximum: 99 + description: Column of the time, when separate. + - in: query + name: referenceLow + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: referenceHigh + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: referenceRange + schema: + description: Column of a printed range such as `4.0-5.6` or `< 200`. + type: integer + minimum: 0 + maximum: 99 + description: Column of a printed range such as `4.0-5.6` or `< 200`. + - in: query + name: panel + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: note + schema: + description: Column of a note; stored encrypted. + type: integer + minimum: 0 + maximum: 99 + description: Column of a note; stored encrypted. + requestBody: + required: true + content: + text/csv: + schema: + type: string + example: |- + Analyte;Value;Unit;Date;Range + HbA1c;5,4;%;01.03.2025;4,0-5,6 + Urine protein;negative;;01.03.2025; + responses: + "200": + description: Per-file and per-row outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/LabsCsvImportEnvelope" + "401": *a1 + "403": + description: "`module.disabled`: the module the import writes to is switched off." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: "`import.csv.too_large`: the file is larger than 16 MB." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`import.csv.content_type`: the body is not `text/csv`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/import/medications-csv: + post: + tags: + - Import + summary: Import medications from CSV + description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the + separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its + own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is + refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the + whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 + checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with + `module.disabled` when the module is switched off. Maps name, dose (as written), daily intake times, an + as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times + unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a + course. Reminders stay off unless `reminders=1`. A medication the record holds under the same name and dose is a + duplicate. At most 500 data rows." + parameters: + - in: query + name: dryRun + schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" + type: string + enum: + - "0" + - "1" + - "true" + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." + - in: query + name: header + schema: + description: "`1` when the first row holds column names and is not data." + default: "1" + type: string + enum: + - "0" + - "1" + description: "`1` when the first row holds column names and is not data." + - in: query + name: delimiter + schema: + default: auto + description: Column separator; `auto` detects it from the first line. + type: string + enum: + - auto + - comma + - semicolon + - tab + description: Column separator; `auto` detects it from the first line. + - in: query + name: decimal + schema: + default: dot + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + type: string + enum: + - dot + - comma + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + - in: query + name: dateOrder + schema: + default: iso + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + type: string + enum: + - iso + - dmy + - mdy + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + - in: query + name: timezone + schema: + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + type: string + maxLength: 64 + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + - in: query + name: name + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the medication name (required). + required: true + description: Column of the medication name (required). + - in: query + name: dose + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the dose as written, e.g. `10 mg` (required). + required: true + description: Column of the dose as written, e.g. `10 mg` (required). + - in: query + name: times + schema: + description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or + `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the + app. A row needs times unless it is marked as needed." + type: integer + minimum: 0 + maximum: 99 + description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or + `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. + A row needs times unless it is marked as needed." + - in: query + name: asNeeded + schema: + description: Column marking an as-needed medication (yes/no). + type: integer + minimum: 0 + maximum: 99 + description: Column marking an as-needed medication (yes/no). + - in: query + name: startsOn + schema: + description: Column of the start date. + type: integer + minimum: 0 + maximum: 99 + description: Column of the start date. + - in: query + name: endsOn + schema: + description: Column of the end date. + type: integer + minimum: 0 + maximum: 99 + description: Column of the end date. + - in: query + name: reminders + schema: + description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts + reminding on its own." + default: "0" + type: string + enum: + - "0" + - "1" + description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts + reminding on its own." + requestBody: + required: true + content: + text/csv: + schema: + type: string + example: |- + name,dose,times,as needed,start,end + Ramipril,5 mg,08:00,,2025-01-01, + Ibuprofen,400 mg,,yes,, + responses: + "200": + description: Per-file and per-row outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationsCsvImportEnvelope" + "401": *a1 + "403": + description: "`module.disabled`: the module the import writes to is switched off." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: "`import.csv.too_large`: the file is larger than 16 MB." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`import.csv.content_type`: the body is not `text/csv`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/import: post: tags: @@ -27550,6 +28332,41 @@ components: speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)." + ActivityGoalsPatch: + type: object + properties: + workoutsPerWeek: + description: Workouts per week (Monday to Sunday). `null` clears the goal. + anyOf: + - type: integer + minimum: 1 + maximum: 28 + - type: "null" + activeMinutesPerWeek: + description: "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed + over the week. `null` clears the goal." + anyOf: + - type: integer + minimum: 10 + maximum: 5000 + - type: "null" + stepsPerWeek: + description: Steps per week. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 700000 + - type: "null" + stepsPerMonth: + description: Steps per calendar month. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 3000000 + - type: "null" + additionalProperties: false + description: The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is + absent stays as it is. Unknown keys are refused. DeviceRegisterRequest: type: object properties: @@ -36488,10 +37305,11 @@ components: one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a - credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, - ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, - ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider - — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + credential of its own carries the generic `http.unauthenticated`. activity — + activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, + ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, + ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, + ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, @@ -36542,7 +37360,8 @@ components: http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — - import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — + import.apple_health.busy, import.csv.content_type, import.csv.empty, import.csv.too_large, + import.rate_limited, import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, @@ -39101,6 +39920,7 @@ components: documentsLayoutJson: {} moduleListLayoutJson: {} previewFeaturesJson: {} + activityGoalsJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -49141,6 +49961,1057 @@ components: - index - status additionalProperties: false + ActivityCalendarEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityCalendar" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityCalendar: + type: object + properties: + period: + type: string + enum: + - week + - month + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + gridStart: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First day drawn (the Monday on or before `start`). + gridEnd: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Last day drawn (the Sunday on or after `end`). + today: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + timezone: + type: string + description: The IANA zone the days are cut in. + loadType: + anyOf: + - type: string + enum: + - DAY_STRAIN + - CARDIO_LOAD + - STRAIN_SCORE + - type: "null" + description: Which training-load score `load` carries; null when the account has none. + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + inPeriod: + type: boolean + future: + type: boolean + steps: + type: + - number + - "null" + activeMinutes: + description: The larger of the day's exercise minutes and workout minutes (never their sum). + type: + - number + - "null" + workoutCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + workoutsBySport: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + load: + type: + - number + - "null" + level: + type: object + properties: + steps: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + activeMinutes: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + load: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + required: + - steps + - activeMinutes + - load + additionalProperties: false + required: + - date + - inPeriod + - future + - steps + - activeMinutes + - workoutCount + - workoutsBySport + - load + - level + additionalProperties: false + weeks: + type: array + items: + type: object + properties: + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + steps: + type: + - number + - "null" + activeMinutes: + type: + - number + - "null" + workouts: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + goals: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + description: The weekly goals for a week that has begun. + required: + - start + - end + - steps + - activeMinutes + - workouts + - goals + additionalProperties: false + baseline: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityBaselineStats" + activeMinutes: + $ref: "#/components/schemas/ActivityBaselineStats" + load: + $ref: "#/components/schemas/ActivityBaselineStats" + required: + - steps + - activeMinutes + - load + additionalProperties: false + summary: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityPeriodComparison" + activeMinutes: + $ref: "#/components/schemas/ActivityPeriodComparison" + workouts: + $ref: "#/components/schemas/ActivityPeriodComparison" + load: + $ref: "#/components/schemas/ActivityPeriodComparison" + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + sports: + type: array + items: + type: object + properties: + sportType: + type: string + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - sportType + - count + additionalProperties: false + goals: + $ref: "#/components/schemas/ActivityGoals" + goalProgress: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + available: + type: object + properties: + steps: + type: boolean + activeMinutes: + type: boolean + workouts: + type: boolean + load: + type: boolean + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + previous: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: A day in the previous period. + next: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: A day in the next period, or null when that lies after today. + required: + - period + - start + - end + - gridStart + - gridEnd + - today + - timezone + - loadType + - days + - weeks + - baseline + - summary + - sports + - goals + - goalProgress + - available + - previous + - next + additionalProperties: false + ActivityGoalProgress: + type: object + properties: + key: + type: string + enum: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + target: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + type: number + met: + type: boolean + required: + - key + - target + - value + - met + additionalProperties: false + ActivityBaselineStats: + anyOf: + - type: object + properties: + median: + type: number + p10: + type: number + p25: + type: number + p75: + type: number + p90: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days with a value the spread was computed from. + required: + - median + - p10 + - p25 + - p75 + - p90 + - days + additionalProperties: false + - type: "null" + ActivityPeriodComparison: + type: object + properties: + value: + description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)." + type: + - number + - "null" + daysWithData: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + usual: + description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. + Null with fewer than three earlier periods of data." + type: + - number + - "null" + usualToDate: + description: The usual figure for the days elapsed so far; equals `usual` once the period is over. + type: + - number + - "null" + referencePeriods: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - value + - daysWithData + - usual + - usualToDate + - referencePeriods + additionalProperties: false + ActivityGoals: + type: object + properties: + workoutsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + activeMinutesPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerMonth: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + additionalProperties: false + description: The person's own goals; `null` = not set. There is no default goal. + ActivityGoalsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityGoalsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayProvenanceEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayProvenance" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayProvenance: + type: object + properties: + type: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + metricKey: + type: + - string + - "null" + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + pickReason: + anyOf: + - type: string + enum: + - only_source + - highest_on_ladder + - name_tiebreak + - not_ranked + description: "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's + sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this + metric has no ladder, every source counts." + - type: "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + rank: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Position on the ladder, 0 = highest; 90 = not on the ladder. + readings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + description: "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), + the mean of the readings otherwise." + type: + - number + - "null" + min: + type: + - number + - "null" + max: + type: + - number + - "null" + firstAt: + type: string + description: ISO instant of the first reading that day. + lastAt: + type: string + unit: + type: + - string + - "null" + devices: + type: array + items: + type: string + description: Device kinds the readings carry; `unknown` when untagged. + pass: + anyOf: + - type: string + enum: + - lower_on_ladder + - not_on_ladder + - name_tiebreak + - type: "null" + description: Why this source was not the one picked; null for the picked one. + required: + - source + - rank + - readings + - value + - min + - max + - firstAt + - lastAt + - unit + - devices + - pass + additionalProperties: false + required: + - type + - date + - aggregation + - ranked + - metricKey + - ladder + - picked + - pickReason + - sources + additionalProperties: false + SourceComparisonEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SourceComparison" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SourceComparison: + type: object + properties: + type: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + from: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + to: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + unit: + type: + - string + - "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - source + - days + additionalProperties: false + a: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + b: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + a: + type: + - number + - "null" + b: + type: + - number + - "null" + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + required: + - date + - a + - b + - picked + additionalProperties: false + summary: + type: object + properties: + daysA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysBoth: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + meanA: + type: + - number + - "null" + meanB: + type: + - number + - "null" + meanDifference: + type: + - number + - "null" + meanAbsoluteDifference: + type: + - number + - "null" + pickedA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + pickedB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - daysA + - daysB + - daysBoth + - meanA + - meanB + - meanDifference + - meanAbsoluteDifference + - pickedA + - pickedB + additionalProperties: false + required: + - type + - from + - to + - aggregation + - ranked + - ladder + - unit + - sources + - a + - b + - days + - summary + additionalProperties: false DeviceRegisterResponse: type: object properties: @@ -70582,6 +72453,180 @@ components: - data - error additionalProperties: false + LabsCsvImportEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordImportResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RecordImportResult: + type: object + properties: + kind: + type: string + enum: + - labs + - medications + dryRun: + type: boolean + rows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Data rows read (the header excluded). + valid: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows that passed every check. + inserted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows written; in a dry run, the rows that would be. + duplicates: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the + same file therefore writes nothing. + failed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows refused; each is listed in `errors`. + errors: + type: array + items: + type: object + properties: + line: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1-based line of the file. + reason: + type: string + enum: + - missing_value + - invalid_number + - ambiguous_number + - missing_unit + - invalid_date + - invalid_time + - ambiguous_date + - future_date + - text_too_long + - unit_mismatch + - invalid_reference_range + - invalid_times + - missing_times + - invalid_yes_no + - as_needed_with_times + - end_before_start + - invalid_medication + - oversized_row + - row_limit + - duplicate + field: + description: The mapped field at fault. + type: string + required: + - line + - reason + additionalProperties: false + description: Refused and duplicate rows, in file order, at most 500. + errorsTruncated: + type: boolean + markers: + description: "Lab imports only: how the file's names meet the markers." + type: object + properties: + matched: + type: array + items: + type: object + properties: + name: + type: string + description: The name as the file writes it. + marker: + type: string + description: The marker it resolves to. + how: + type: string + enum: + - name + - alias + - catalog + required: + - name + - marker + - how + additionalProperties: false + description: File names that resolve to an existing marker, at most 200. + created: + type: array + items: + type: string + description: Names that become new markers, at most 200. + required: + - matched + - created + additionalProperties: false + preview: + type: array + items: + type: object + propertyNames: + type: string + additionalProperties: + type: + - string + - number + - boolean + - "null" + description: The first 20 valid rows as they will be stored (dry run only). + required: + - kind + - dryRun + - rows + - valid + - inserted + - duplicates + - failed + - errors + - errorsTruncated + - preview + additionalProperties: false + MedicationsCsvImportEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordImportResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false JsonImportStatsEnvelope: type: object properties: diff --git a/e2e/activity-calendar.spec.ts b/e2e/activity-calendar.spec.ts new file mode 100644 index 000000000..3cfae6517 --- /dev/null +++ b/e2e/activity-calendar.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The activity calendar on the workouts page (D1036), at phone width: the + * card renders a whole-week grid, pages back a period, switches to the week + * view, and the goals sheet saves a goal the calendar then reports. + */ +test.describe("activity calendar", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("draws the month, pages back and switches to a week", async ({ + page, + }) => { + await page.goto("/insights/workouts"); + const card = page.locator('[data-slot="activity-calendar"]'); + await expect(card).toBeVisible(); + await expect( + page.locator('[data-slot="activity-calendar-label"]'), + ).not.toHaveText(""); + + const days = page.locator('[data-slot="activity-calendar-day"]'); + const monthCount = await days.count(); + if (monthCount > 0) expect(monthCount % 7).toBe(0); + + const label = page.locator('[data-slot="activity-calendar-label"]'); + const before = await label.textContent(); + await page.locator('[data-slot="activity-calendar-previous"]').click(); + await expect(label).not.toHaveText(before ?? ""); + + await card.getByRole("radio", { name: "Week" }).click(); + if ((await days.count()) > 0) await expect(days).toHaveCount(7); + + // Nothing in the card pushes the page sideways on a phone. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test("saves a goal and shows its progress", async ({ page }) => { + await page.goto("/insights/workouts"); + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill("150000"); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toBeVisible(); + + // Clear it again so the account is left as it was found. + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill(""); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toHaveCount(0); + }); +}); diff --git a/e2e/record-csv-import.spec.ts b/e2e/record-csv-import.spec.ts new file mode 100644 index 000000000..5ad83d964 --- /dev/null +++ b/e2e/record-csv-import.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Lab results from a spreadsheet (#1215), at phone width, against the real + * server: the mapping is suggested from the headers, the check reports a + * refused row by line and writes nothing, the import writes the rest, and a + * second import of the same file only finds duplicates. + */ +function csv(): string { + const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + return [ + "Analyte;Value;Unit;Date", + `E2E Marker CSV;5,4;mmol/L;${day}`, + `E2E Marker CSV;1.234;mmol/L;${day}`, + ].join("\n"); +} + +test.describe("record CSV import", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("checks, imports and then only finds duplicates", async ({ page }) => { + await page.goto("/settings/export"); + await page.locator('[data-slot="record-import-open-labs"]').click(); + const sheet = page.locator('[data-slot="record-import-sheet"]'); + await expect(sheet).toBeVisible(); + const file = { + name: "labs.csv", + mimeType: "text/csv", + buffer: Buffer.from(csv()), + }; + await page.locator('[data-slot="record-import-file"]').setInputFiles(file); + await expect( + page.locator('[data-slot="record-import-map-analyte"]'), + ).toHaveValue("0"); + await expect( + page.locator('[data-slot="record-import-map-value"]'), + ).toHaveValue("1"); + + await page.locator('[data-slot="record-import-check"]').click(); + await expect( + page.locator('[data-slot="record-import-checked"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="record-import-error-row"]'), + ).toHaveCount(1); + + await page.locator('[data-slot="record-import-run"]').click(); + await expect( + page.locator('[data-slot="record-import-done"]'), + ).toBeVisible(); + + // Second run of the same file. + await page.keyboard.press("Escape"); + await page.locator('[data-slot="record-import-open-labs"]').click(); + await page.locator('[data-slot="record-import-file"]').setInputFiles(file); + await page.locator('[data-slot="record-import-check"]').click(); + await expect( + page.locator('[data-slot="record-import-run"]'), + ).toBeDisabled(); + }); +}); diff --git a/e2e/source-provenance.spec.ts b/e2e/source-provenance.spec.ts new file mode 100644 index 000000000..24348d13d --- /dev/null +++ b/e2e/source-provenance.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The provenance page (#1204) at phone width, against mocked reads: the + * day's sources with the used one marked, the comparison table, and the + * link from Settings > Source priority. + */ +const DAY = { + type: "ACTIVITY_STEPS", + date: "2026-10-01", + aggregation: "sum", + ranked: true, + metricKey: "steps", + ladder: ["APPLE_HEALTH", "FITBIT"], + picked: "APPLE_HEALTH", + pickReason: "highest_on_ladder", + sources: [ + { + source: "APPLE_HEALTH", + rank: 0, + readings: 24, + value: 9500, + min: 10, + max: 900, + firstAt: "2026-10-01T06:00:00.000Z", + lastAt: "2026-10-01T20:00:00.000Z", + unit: "steps", + devices: ["phone", "watch"], + pass: null, + }, + { + source: "FITBIT", + rank: 1, + readings: 1, + value: 8800, + min: 8800, + max: 8800, + firstAt: "2026-10-01T23:00:00.000Z", + lastAt: "2026-10-01T23:00:00.000Z", + unit: "steps", + devices: ["band"], + pass: "lower_on_ladder", + }, + ], +}; +const COMPARE = { + type: "ACTIVITY_STEPS", + from: "2026-09-02", + to: "2026-10-01", + aggregation: "sum", + ranked: true, + ladder: ["APPLE_HEALTH", "FITBIT"], + unit: "steps", + sources: [ + { source: "APPLE_HEALTH", days: 30 }, + { source: "FITBIT", days: 28 }, + ], + a: "APPLE_HEALTH", + b: "FITBIT", + days: [{ date: "2026-10-01", a: 9500, b: 8800, picked: "APPLE_HEALTH" }], + summary: { + daysA: 30, + daysB: 28, + daysBoth: 28, + meanA: 9100, + meanB: 8700, + meanDifference: -400, + meanAbsoluteDifference: 450, + pickedA: 30, + pickedB: 0, + }, +}; + +test.describe("source provenance", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("explains the day's pick and compares two sources", async ({ page }) => { + await page.route(/\/api\/measurements\/provenance\/compare/, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data: COMPARE, error: null }), + }), + ); + await page.route(/\/api\/measurements\/provenance\?/, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data: DAY, error: null }), + }), + ); + // Yesterday in UTC: the page refuses nothing, but a day the date field + // cannot pick (after today) would read as a fixture gone stale. + const day = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + await page.goto(`/insights/sources?type=ACTIVITY_STEPS&date=${day}`); + await expect( + page.locator('[data-slot="provenance-source-row"][data-picked="true"]'), + ).toHaveAttribute("data-source", "APPLE_HEALTH"); + await expect( + page.locator('[data-slot="provenance-verdict"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="provenance-compare-row"]'), + ).toHaveCount(1); + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test("is reachable from Settings > Source priority", async ({ page }) => { + await page.goto("/settings/sources"); + await page.locator('[data-slot="sources-provenance-cross-link"]').click(); + await expect(page).toHaveURL(/\/insights\/sources/); + }); +}); diff --git a/messages/de.json b/messages/de.json index 2d9c44cc9..316537b5a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Klinische Obergrenze", "clinicalLower": "Klinische Untergrenze" } + }, + "activityCalendar": { + "title": "Aktivitätskalender", + "periodLabel": "Zeitraum", + "period": { + "week": "Woche", + "month": "Monat" + }, + "weekRange": "{start} bis {end}", + "previous": "Vorheriger Zeitraum", + "next": "Nächster Zeitraum", + "metricLabel": "Tage einfärben nach", + "metric": { + "steps": "Schritte", + "activeMinutes": "Aktive Minuten", + "load": "Trainingslast" + }, + "empty": "In diesem Zeitraum gibt es noch keine Schritte, aktiven Minuten oder Trainings.", + "loadError": "Der Aktivitätskalender konnte nicht geladen werden.", + "weekGoalsColumn": "Wochenziele", + "weekGoalsMet": "Alle Wochenziele erreicht", + "weekGoalsNotMet": "Nicht alle Wochenziele erreicht", + "cell": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings: {sports}" + }, + "level": { + "none": "Keine Daten", + "unrated": "Noch zu wenig Verlauf", + "low": "Weit unter deinem Üblichen", + "below": "Unter deinem Üblichen", + "usual": "Etwa wie üblich", + "above": "Über deinem Üblichen", + "high": "Weit über deinem Üblichen" + }, + "legend": "Verglichen mit deinen 12 Wochen davor:", + "noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.", + "summary": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings", + "load": "Durchschnittliche Trainingslast {value}", + "usual": "Bis hierher üblich bei dir: {value}", + "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainings pro Woche", + "activeMinutesPerWeek": "Aktive Minuten pro Woche", + "stepsPerWeek": "Schritte pro Woche", + "stepsPerMonth": "Schritte pro Monat" + }, + "goalOf": "{value} von {target}", + "setGoals": "Ziele festlegen", + "editGoals": "Ziele ändern", + "goalsSheet": { + "title": "Aktivitätsziele", + "description": "Lass ein Feld leer, wenn du dort kein Ziel möchtest.", + "none": "Kein Ziel", + "invalid": "Eine ganze Zahl von {min} bis {max}.", + "saveError": "Die Ziele konnten nicht gespeichert werden." + }, + "crossLink": { + "title": "Aktivitätskalender", + "body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.", + "cta": "Kalender öffnen" + } + }, + "sourceProvenance": { + "title": "Woher die Werte kommen", + "description": "Welche Quelle einen Wert an jedem Tag geliefert hat und welche deine Quellen-Priorität verwendet.", + "metric": "Messwert", + "day": "Tag", + "dayTitle": "Quellen an diesem Tag", + "loadError": "Die Quellen konnten nicht geladen werden.", + "dayEmpty": "An diesem Tag gibt es keine Werte dieser Art.", + "reason": { + "only_source": "{source} ist an diesem Tag die einzige Quelle.", + "highest_on_ladder": "{source} wird verwendet: Unter den Quellen dieses Tages steht sie in deiner Quellen-Priorität am höchsten.", + "name_tiebreak": "{source} wird verwendet: Keine der Quellen dieses Tages steht in deiner Quellen-Priorität, deshalb entscheidet der Name.", + "not_ranked": "Für diesen Wert gibt es keine Quellen-Priorität: Die Werte aller Quellen zählen." + }, + "pass": { + "lower_on_ladder": "Steht in deiner Quellen-Priorität weiter unten.", + "not_on_ladder": "Steht für diesen Wert nicht in deiner Quellen-Priorität.", + "name_tiebreak": "Gleicher Rang wie die verwendete Quelle; der Name hat entschieden." + }, + "used": "Verwendet", + "readings": "{count} Messungen, Geräte: {devices}", + "ladder": "Deine Reihenfolge für diesen Wert: {ladder}", + "utcNote": "Die Quelle wird pro Tag von Mitternacht bis Mitternacht UTC gewählt, genauso wie bei den Diagrammen.", + "openSettings": "Reihenfolge in der Quellen-Priorität ändern", + "compareTitle": "Zwei Quellen vergleichen", + "range": "Zeitraum", + "rangeDays": "{days} Tage", + "compareNeedsTwo": "In diesem Zeitraum hat nur eine Quelle Werte dieser Art.", + "side": { + "a": "Erste Quelle", + "b": "Zweite Quelle" + }, + "daysBoth": "Tage mit beiden", + "meanOf": "Durchschnitt von {source}", + "meanAbsDiff": "Durchschnittliche Abweichung", + "pickedDays": "Verwendete Tage (erste / zweite)", + "pickedSplit": "{a} / {b}", + "compareNote": "Die Durchschnitte zählen nur Tage, an denen beide Quellen Werte haben, damit Gleiches mit Gleichem verglichen wird. Fett ist der Wert, den die Diagramme verwenden.", + "settingsHint": "Was diese Reihenfolge Tag für Tag entscheidet:", + "settingsHintLink": "Woher die Werte kommen", + "metricLink": "Woher diese Werte kommen" + }, + "recordImport": { + "title": "Laborwerte und Medikamente aus einer Tabelle", + "description": "Hol dir Laborwerte aus Jahren oder eine Medikamentenliste aus einer CSV-Datei.", + "detail": "Du legst fest, welche Spalte was enthält. HealthLog prüft jede Zeile vorher und importiert erst, wenn du bestätigst. Zeilen, die schon da sind, werden übersprungen.", + "kind": { + "labs": "Laborwerte", + "medications": "Medikamente" + }, + "sheetTitle": { + "labs": "Laborwerte importieren", + "medications": "Medikamente importieren" + }, + "sheetDescription": { + "labs": "Marker werden deinen zugeordnet, Einheiten umgerechnet, wo die Umrechnung bekannt ist.", + "medications": "Name, Dosis, tägliche Uhrzeiten, bei Bedarf, Beginn und Ende. Andere Rhythmen legst du danach in der App fest." + }, + "file": "CSV-Datei", + "template": "Vorlage herunterladen", + "mapping": "Welche Spalte enthält was", + "required": "(Pflicht)", + "notInFile": "Nicht in der Datei", + "column": "Spalte {n}", + "delimiter": "Trennzeichen", + "delimiterOption": { + "comma": "Komma", + "semicolon": "Semikolon", + "tab": "Tabulator" + }, + "decimal": "Dezimalzeichen", + "decimalOption": { + "dot": "Punkt (5.4)", + "comma": "Komma (5,4)" + }, + "dateOrder": "Datumsformat", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (Tag zuerst)", + "mdy": "03/01/2025 (Monat zuerst)" + }, + "header": "Die erste Zeile enthält Spaltennamen", + "reminders": "Erinnerungen für importierte Medikamente einschalten", + "timezoneNote": "Uhrzeiten ohne Zeitzone werden in der Zeitzone deines Kontos gelesen. Ein Datum ohne Uhrzeit bleibt dieser Kalendertag.", + "missing": "Noch auszuwählen: {fields}", + "check": "Datei prüfen", + "runIdle": "Importieren", + "run": "{count} Zeilen importieren", + "close": "Schließen", + "summaryCheck": "{rows} Zeilen gelesen: {inserted} können importiert werden, {duplicates} sind schon da, {failed} haben ein Problem.", + "summaryDone": "{inserted} Zeilen importiert. Übersprungen: {duplicates}, die schon da waren, und {failed} mit einem Problem.", + "markersMatched": "Deinen Markern zugeordnet:", + "markersCreated": "Neue Marker:", + "line": "Zeile {line}", + "errorsTruncated": "Nur die ersten 500 Probleme werden aufgelistet.", + "error": { + "tooLarge": "Die Datei ist größer als 16 MB.", + "rateLimited": "Zu viele Importe in der letzten Stunde. Versuch es später noch einmal.", + "invalid": "Die Datei oder die Spaltenauswahl konnte nicht gelesen werden.", + "failed": "Der Import hat nicht geklappt. Bitte versuch es noch einmal." + }, + "reason": { + "missing_value": "Eine Pflichtzelle ist leer", + "invalid_number": "Keine Zahl", + "ambiguous_number": "Die Zahl ist mehrdeutig; prüf das Dezimalzeichen", + "missing_unit": "Eine Zahl braucht eine Einheit", + "invalid_date": "Kein Datum im gewählten Format", + "invalid_time": "Keine Uhrzeit", + "ambiguous_date": "Das Datum ist mehrdeutig; nutz eine vierstellige Jahreszahl und das gewählte Format", + "future_date": "Das Datum liegt in der Zukunft", + "text_too_long": "Der Text ist zu lang", + "unit_mismatch": "Diese Einheit lässt sich nicht in die Einheit des Markers umrechnen", + "invalid_reference_range": "Der Referenzbereich ist nicht lesbar", + "invalid_times": "Die Uhrzeiten sind nicht lesbar (z. B. 08:00 20:00)", + "missing_times": "Trag Einnahmezeiten ein oder markier das Medikament als bei Bedarf", + "invalid_yes_no": "Bitte ja oder nein", + "as_needed_with_times": "Ein Medikament bei Bedarf hat keine festen Uhrzeiten", + "end_before_start": "Das Ende liegt vor dem Beginn", + "invalid_medication": "So lässt sich das Medikament nicht speichern", + "oversized_row": "Die Zeile ist zu lang oder zu breit", + "row_limit": "Über der Zeilengrenze für einen Import", + "duplicate": "Schon da, übersprungen" + }, + "field": { + "analyte": "Marker", + "value": "Ergebnis", + "unit": "Einheit", + "date": "Datum", + "time": "Uhrzeit", + "referenceRange": "Referenzbereich", + "referenceLow": "Referenz unten", + "referenceHigh": "Referenz oben", + "panel": "Gruppe", + "note": "Notiz", + "name": "Name", + "dose": "Dosis", + "times": "Uhrzeiten", + "asNeeded": "Bei Bedarf", + "startsOn": "Beginn", + "endsOn": "Ende" + } } } diff --git a/messages/en.json b/messages/en.json index f79cd68f2..5c2a679bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Clinical upper limit", "clinicalLower": "Clinical lower limit" } + }, + "activityCalendar": { + "title": "Activity calendar", + "periodLabel": "Calendar span", + "period": { + "week": "Week", + "month": "Month" + }, + "weekRange": "{start} to {end}", + "previous": "Previous period", + "next": "Next period", + "metricLabel": "Colour the days by", + "metric": { + "steps": "Steps", + "activeMinutes": "Active minutes", + "load": "Training load" + }, + "empty": "No steps, active minutes or workouts in this period yet.", + "loadError": "The activity calendar could not be loaded.", + "weekGoalsColumn": "Weekly goals", + "weekGoalsMet": "All weekly goals met", + "weekGoalsNotMet": "Not all weekly goals met", + "cell": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts: {sports}" + }, + "level": { + "none": "No data", + "unrated": "Not enough history yet", + "low": "Far below your usual", + "below": "Below your usual", + "usual": "About your usual", + "above": "Above your usual", + "high": "Far above your usual" + }, + "legend": "Compared with your 12 weeks before:", + "noBaseline": "Your usual range shows after 14 days with data.", + "summary": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts", + "load": "Average training load {value}", + "usual": "Your usual by this point: {value}", + "noUsual": "Not enough earlier weeks or months to compare with yet." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Workouts per week", + "activeMinutesPerWeek": "Active minutes per week", + "stepsPerWeek": "Steps per week", + "stepsPerMonth": "Steps per month" + }, + "goalOf": "{value} of {target}", + "setGoals": "Set goals", + "editGoals": "Change goals", + "goalsSheet": { + "title": "Activity goals", + "description": "Leave a field empty for no goal.", + "none": "No goal", + "invalid": "A whole number from {min} to {max}.", + "saveError": "The goals could not be saved." + }, + "crossLink": { + "title": "Activity calendar", + "body": "Your steps as a week or a month, against your own usual.", + "cta": "Open calendar" + } + }, + "sourceProvenance": { + "title": "Where values come from", + "description": "Which source delivered a metric each day, and which one your source priority uses.", + "metric": "Metric", + "day": "Day", + "dayTitle": "Sources on this day", + "loadError": "The sources could not be loaded.", + "dayEmpty": "No readings of this metric on this day.", + "reason": { + "only_source": "{source} is the only source on this day.", + "highest_on_ladder": "{source} is used: it stands highest in your source priority among this day's sources.", + "name_tiebreak": "{source} is used: none of this day's sources is in your source priority, so the name decided.", + "not_ranked": "This metric has no source priority: every source's readings count." + }, + "pass": { + "lower_on_ladder": "Lower in your source priority.", + "not_on_ladder": "Not in your source priority for this metric.", + "name_tiebreak": "Same rank as the source used; the name decided." + }, + "used": "Used", + "readings": "{count} readings, devices: {devices}", + "ladder": "Your order for this metric: {ladder}", + "utcNote": "The source is chosen per day from midnight to midnight UTC, the same way the charts choose it.", + "openSettings": "Change the order in Source priority", + "compareTitle": "Compare two sources", + "range": "Range", + "rangeDays": "{days} days", + "compareNeedsTwo": "Only one source has readings of this metric in this range.", + "side": { + "a": "First source", + "b": "Second source" + }, + "daysBoth": "Days with both", + "meanOf": "Average from {source}", + "meanAbsDiff": "Average difference", + "pickedDays": "Days used (first / second)", + "pickedSplit": "{a} / {b}", + "compareNote": "Averages cover only the days both sources have, so they compare like with like. The value in bold is the one the charts use.", + "settingsHint": "To see what this order decides day by day:", + "settingsHintLink": "Where values come from", + "metricLink": "Where these values come from" + }, + "recordImport": { + "title": "Lab results and medications from a spreadsheet", + "description": "Bring years of lab results or a medication list in from a CSV file.", + "detail": "You choose which column holds what, HealthLog checks every row first and imports nothing until you confirm. Rows already in your record are skipped.", + "kind": { + "labs": "Lab results", + "medications": "Medications" + }, + "sheetTitle": { + "labs": "Import lab results", + "medications": "Import medications" + }, + "sheetDescription": { + "labs": "Markers are matched to yours, units converted where the conversion is known.", + "medications": "Name, dose, daily times, as needed, start and end. Other rhythms you set up in the app afterwards." + }, + "file": "CSV file", + "template": "Download template", + "mapping": "Which column holds what", + "required": "(required)", + "notInFile": "Not in the file", + "column": "Column {n}", + "delimiter": "Separator", + "delimiterOption": { + "comma": "Comma", + "semicolon": "Semicolon", + "tab": "Tab" + }, + "decimal": "Decimal mark", + "decimalOption": { + "dot": "Dot (5.4)", + "comma": "Comma (5,4)" + }, + "dateOrder": "Date format", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (day first)", + "mdy": "03/01/2025 (month first)" + }, + "header": "The first row holds column names", + "reminders": "Turn reminders on for imported medications", + "timezoneNote": "Times without a time zone are read in your account's time zone. A date without a time is kept as that calendar date.", + "missing": "Still to choose: {fields}", + "check": "Check file", + "runIdle": "Import", + "run": "Import {count} rows", + "close": "Close", + "summaryCheck": "{rows} rows read: {inserted} can be imported, {duplicates} already in your record, {failed} with a problem.", + "summaryDone": "Imported {inserted} rows. Skipped {duplicates} already in your record, {failed} with a problem.", + "markersMatched": "Matched to your markers:", + "markersCreated": "New markers:", + "line": "Line {line}", + "errorsTruncated": "Only the first 500 problems are listed.", + "error": { + "tooLarge": "The file is larger than 16 MB.", + "rateLimited": "Too many imports in the last hour. Try again later.", + "invalid": "The file or the column choice could not be read.", + "failed": "The import did not go through. Please try again." + }, + "reason": { + "missing_value": "A required cell is empty", + "invalid_number": "Not a number", + "ambiguous_number": "The number reads two ways; check the decimal mark", + "missing_unit": "A number needs a unit", + "invalid_date": "Not a date in the chosen order", + "invalid_time": "Not a time of day", + "ambiguous_date": "The date reads two ways; use a four-digit year and the chosen order", + "future_date": "The date lies in the future", + "text_too_long": "The text is too long", + "unit_mismatch": "This unit does not convert into the marker's unit", + "invalid_reference_range": "The reference range cannot be read", + "invalid_times": "The times cannot be read (use 08:00 20:00)", + "missing_times": "Add intake times, or mark the medication as needed", + "invalid_yes_no": "Use yes or no", + "as_needed_with_times": "An as-needed medication has no fixed times", + "end_before_start": "The end lies before the start", + "invalid_medication": "The medication cannot be saved like this", + "oversized_row": "The row is too long or too wide", + "row_limit": "Past the row limit for one import", + "duplicate": "Already in your record, skipped" + }, + "field": { + "analyte": "Marker", + "value": "Result", + "unit": "Unit", + "date": "Date", + "time": "Time", + "referenceRange": "Reference range", + "referenceLow": "Reference low", + "referenceHigh": "Reference high", + "panel": "Panel", + "note": "Note", + "name": "Name", + "dose": "Dose", + "times": "Times", + "asNeeded": "As needed", + "startsOn": "Start", + "endsOn": "End" + } } } diff --git a/messages/es.json b/messages/es.json index 92e4b39ef..df408bccd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Límite clínico superior", "clinicalLower": "Límite clínico inferior" } + }, + "activityCalendar": { + "title": "Calendario de actividad", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Semana", + "month": "Mes" + }, + "weekRange": "{start} a {end}", + "previous": "Periodo anterior", + "next": "Periodo siguiente", + "metricLabel": "Colorear los días por", + "metric": { + "steps": "Pasos", + "activeMinutes": "Minutos activos", + "load": "Carga de entrenamiento" + }, + "empty": "Todavía no hay pasos, minutos activos ni entrenamientos en este periodo.", + "loadError": "No se pudo cargar el calendario de actividad.", + "weekGoalsColumn": "Objetivos semanales", + "weekGoalsMet": "Todos los objetivos semanales cumplidos", + "weekGoalsNotMet": "No se cumplieron todos los objetivos semanales", + "cell": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos: {sports}" + }, + "level": { + "none": "Sin datos", + "unrated": "Aún no hay suficiente historial", + "low": "Muy por debajo de lo habitual", + "below": "Por debajo de lo habitual", + "usual": "Como de costumbre", + "above": "Por encima de lo habitual", + "high": "Muy por encima de lo habitual" + }, + "legend": "Comparado con tus 12 semanas anteriores:", + "noBaseline": "Tu rango habitual aparece tras 14 días con datos.", + "summary": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos", + "load": "Carga de entrenamiento media {value}", + "usual": "Lo habitual hasta este punto: {value}", + "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Entrenamientos por semana", + "activeMinutesPerWeek": "Minutos activos por semana", + "stepsPerWeek": "Pasos por semana", + "stepsPerMonth": "Pasos por mes" + }, + "goalOf": "{value} de {target}", + "setGoals": "Fijar objetivos", + "editGoals": "Cambiar objetivos", + "goalsSheet": { + "title": "Objetivos de actividad", + "description": "Deja un campo vacío si no quieres un objetivo.", + "none": "Sin objetivo", + "invalid": "Un número entero de {min} a {max}.", + "saveError": "No se pudieron guardar los objetivos." + }, + "crossLink": { + "title": "Calendario de actividad", + "body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.", + "cta": "Abrir calendario" + } + }, + "sourceProvenance": { + "title": "De dónde vienen los valores", + "description": "Qué fuente aportó un valor cada día y cuál usa tu prioridad de fuentes.", + "metric": "Medida", + "day": "Día", + "dayTitle": "Fuentes de este día", + "loadError": "No se pudieron cargar las fuentes.", + "dayEmpty": "No hay valores de esta medida en este día.", + "reason": { + "only_source": "{source} es la única fuente de este día.", + "highest_on_ladder": "Se usa {source}: es la más alta en tu prioridad de fuentes entre las fuentes de este día.", + "name_tiebreak": "Se usa {source}: ninguna fuente de este día está en tu prioridad, así que decidió el nombre.", + "not_ranked": "Esta medida no tiene prioridad de fuentes: cuentan los valores de todas las fuentes." + }, + "pass": { + "lower_on_ladder": "Más abajo en tu prioridad de fuentes.", + "not_on_ladder": "No está en tu prioridad de fuentes para esta medida.", + "name_tiebreak": "Mismo rango que la fuente usada; decidió el nombre." + }, + "used": "Usada", + "readings": "{count} mediciones, dispositivos: {devices}", + "ladder": "Tu orden para esta medida: {ladder}", + "utcNote": "La fuente se elige por día, de medianoche a medianoche UTC, igual que en los gráficos.", + "openSettings": "Cambiar el orden en Prioridad de fuentes", + "compareTitle": "Comparar dos fuentes", + "range": "Periodo", + "rangeDays": "{days} días", + "compareNeedsTwo": "Solo una fuente tiene valores de esta medida en este periodo.", + "side": { + "a": "Primera fuente", + "b": "Segunda fuente" + }, + "daysBoth": "Días con ambas", + "meanOf": "Media de {source}", + "meanAbsDiff": "Diferencia media", + "pickedDays": "Días usados (primera / segunda)", + "pickedSplit": "{a} / {b}", + "compareNote": "Las medias solo cuentan los días en que ambas fuentes tienen valores, para comparar lo comparable. En negrita, el valor que usan los gráficos.", + "settingsHint": "Para ver lo que decide este orden día a día:", + "settingsHintLink": "De dónde vienen los valores", + "metricLink": "De dónde vienen estos valores" + }, + "recordImport": { + "title": "Resultados de laboratorio y medicamentos desde una hoja de cálculo", + "description": "Trae años de resultados o una lista de medicamentos desde un archivo CSV.", + "detail": "Tú eliges qué columna contiene qué; HealthLog revisa cada fila antes y no importa nada hasta que confirmes. Las filas que ya están se omiten.", + "kind": { + "labs": "Resultados de laboratorio", + "medications": "Medicamentos" + }, + "sheetTitle": { + "labs": "Importar resultados", + "medications": "Importar medicamentos" + }, + "sheetDescription": { + "labs": "Los marcadores se asignan a los tuyos y las unidades se convierten cuando se conoce la conversión.", + "medications": "Nombre, dosis, horas diarias, a demanda, inicio y fin. Otros ritmos se configuran después en la app." + }, + "file": "Archivo CSV", + "template": "Descargar plantilla", + "mapping": "Qué columna contiene qué", + "required": "(obligatorio)", + "notInFile": "No está en el archivo", + "column": "Columna {n}", + "delimiter": "Separador", + "delimiterOption": { + "comma": "Coma", + "semicolon": "Punto y coma", + "tab": "Tabulador" + }, + "decimal": "Separador decimal", + "decimalOption": { + "dot": "Punto (5.4)", + "comma": "Coma (5,4)" + }, + "dateOrder": "Formato de fecha", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (día primero)", + "mdy": "03/01/2025 (mes primero)" + }, + "header": "La primera fila contiene los nombres de columna", + "reminders": "Activar recordatorios para los medicamentos importados", + "timezoneNote": "Las horas sin zona horaria se leen en la zona de tu cuenta. Una fecha sin hora se guarda como ese día del calendario.", + "missing": "Falta elegir: {fields}", + "check": "Revisar archivo", + "runIdle": "Importar", + "run": "Importar {count} filas", + "close": "Cerrar", + "summaryCheck": "{rows} filas leídas: {inserted} se pueden importar, {duplicates} ya están, {failed} con un problema.", + "summaryDone": "{inserted} filas importadas. Omitidas: {duplicates} que ya estaban y {failed} con un problema.", + "markersMatched": "Asignados a tus marcadores:", + "markersCreated": "Marcadores nuevos:", + "line": "Línea {line}", + "errorsTruncated": "Solo se listan los primeros 500 problemas.", + "error": { + "tooLarge": "El archivo supera los 16 MB.", + "rateLimited": "Demasiadas importaciones en la última hora. Inténtalo más tarde.", + "invalid": "No se pudo leer el archivo o la elección de columnas.", + "failed": "La importación no se completó. Inténtalo de nuevo." + }, + "reason": { + "missing_value": "Falta una celda obligatoria", + "invalid_number": "No es un número", + "ambiguous_number": "El número se puede leer de dos maneras; revisa el separador decimal", + "missing_unit": "Un número necesita una unidad", + "invalid_date": "No es una fecha en el formato elegido", + "invalid_time": "No es una hora", + "ambiguous_date": "La fecha se puede leer de dos maneras; usa un año de cuatro cifras y el formato elegido", + "future_date": "La fecha está en el futuro", + "text_too_long": "El texto es demasiado largo", + "unit_mismatch": "Esta unidad no se puede convertir a la del marcador", + "invalid_reference_range": "No se puede leer el rango de referencia", + "invalid_times": "No se pueden leer las horas (ej. 08:00 20:00)", + "missing_times": "Añade horas de toma o marca el medicamento como a demanda", + "invalid_yes_no": "Usa sí o no", + "as_needed_with_times": "Un medicamento a demanda no tiene horas fijas", + "end_before_start": "El fin es anterior al inicio", + "invalid_medication": "El medicamento no se puede guardar así", + "oversized_row": "La fila es demasiado larga o ancha", + "row_limit": "Supera el límite de filas por importación", + "duplicate": "Ya está, omitida" + }, + "field": { + "analyte": "Marcador", + "value": "Resultado", + "unit": "Unidad", + "date": "Fecha", + "time": "Hora", + "referenceRange": "Rango de referencia", + "referenceLow": "Referencia mínima", + "referenceHigh": "Referencia máxima", + "panel": "Grupo", + "note": "Nota", + "name": "Nombre", + "dose": "Dosis", + "times": "Horas", + "asNeeded": "A demanda", + "startsOn": "Inicio", + "endsOn": "Fin" + } } } diff --git a/messages/fr.json b/messages/fr.json index d9adb8724..9fd3de26a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Limite clinique haute", "clinicalLower": "Limite clinique basse" } + }, + "activityCalendar": { + "title": "Calendrier d'activité", + "periodLabel": "Période du calendrier", + "period": { + "week": "Semaine", + "month": "Mois" + }, + "weekRange": "{start} au {end}", + "previous": "Période précédente", + "next": "Période suivante", + "metricLabel": "Colorer les jours selon", + "metric": { + "steps": "Pas", + "activeMinutes": "Minutes actives", + "load": "Charge d'entraînement" + }, + "empty": "Aucun pas, aucune minute active ni aucun entraînement sur cette période pour l'instant.", + "loadError": "Le calendrier d'activité n'a pas pu être chargé.", + "weekGoalsColumn": "Objectifs hebdomadaires", + "weekGoalsMet": "Tous les objectifs hebdomadaires atteints", + "weekGoalsNotMet": "Objectifs hebdomadaires pas tous atteints", + "cell": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements : {sports}" + }, + "level": { + "none": "Aucune donnée", + "unrated": "Pas encore assez d'historique", + "low": "Bien en dessous de ton habitude", + "below": "En dessous de ton habitude", + "usual": "Comme d'habitude", + "above": "Au-dessus de ton habitude", + "high": "Bien au-dessus de ton habitude" + }, + "legend": "Comparé à tes 12 semaines précédentes :", + "noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.", + "summary": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements", + "load": "Charge d'entraînement moyenne {value}", + "usual": "Ton habitude à ce stade : {value}", + "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer." + }, + "sportCount": "{sport} : {count}", + "goal": { + "workoutsPerWeek": "Entraînements par semaine", + "activeMinutesPerWeek": "Minutes actives par semaine", + "stepsPerWeek": "Pas par semaine", + "stepsPerMonth": "Pas par mois" + }, + "goalOf": "{value} sur {target}", + "setGoals": "Définir des objectifs", + "editGoals": "Modifier les objectifs", + "goalsSheet": { + "title": "Objectifs d'activité", + "description": "Laisse un champ vide pour ne pas fixer d'objectif.", + "none": "Aucun objectif", + "invalid": "Un nombre entier de {min} à {max}.", + "saveError": "Les objectifs n'ont pas pu être enregistrés." + }, + "crossLink": { + "title": "Calendrier d'activité", + "body": "Tes pas par semaine ou par mois, comparés à ton habitude.", + "cta": "Ouvrir le calendrier" + } + }, + "sourceProvenance": { + "title": "D'où viennent les valeurs", + "description": "Quelle source a fourni une valeur chaque jour, et laquelle ta priorité des sources utilise.", + "metric": "Mesure", + "day": "Jour", + "dayTitle": "Sources de ce jour", + "loadError": "Les sources n'ont pas pu être chargées.", + "dayEmpty": "Aucune valeur de cette mesure ce jour-là.", + "reason": { + "only_source": "{source} est la seule source ce jour-là.", + "highest_on_ladder": "{source} est utilisée : c'est la plus haute dans ta priorité des sources parmi celles de ce jour.", + "name_tiebreak": "{source} est utilisée : aucune source de ce jour n'est dans ta priorité, le nom a donc décidé.", + "not_ranked": "Cette mesure n'a pas de priorité des sources : les valeurs de toutes les sources comptent." + }, + "pass": { + "lower_on_ladder": "Plus bas dans ta priorité des sources.", + "not_on_ladder": "Absente de ta priorité des sources pour cette mesure.", + "name_tiebreak": "Même rang que la source utilisée ; le nom a décidé." + }, + "used": "Utilisée", + "readings": "{count} mesures, appareils : {devices}", + "ladder": "Ton ordre pour cette mesure : {ladder}", + "utcNote": "La source est choisie par jour, de minuit à minuit UTC, comme pour les graphiques.", + "openSettings": "Modifier l'ordre dans Priorité des sources", + "compareTitle": "Comparer deux sources", + "range": "Période", + "rangeDays": "{days} jours", + "compareNeedsTwo": "Une seule source a des valeurs de cette mesure sur cette période.", + "side": { + "a": "Première source", + "b": "Deuxième source" + }, + "daysBoth": "Jours avec les deux", + "meanOf": "Moyenne de {source}", + "meanAbsDiff": "Écart moyen", + "pickedDays": "Jours utilisés (première / deuxième)", + "pickedSplit": "{a} / {b}", + "compareNote": "Les moyennes ne comptent que les jours où les deux sources ont des valeurs, pour comparer ce qui est comparable. En gras, la valeur utilisée par les graphiques.", + "settingsHint": "Pour voir ce que cet ordre décide jour après jour :", + "settingsHintLink": "D'où viennent les valeurs", + "metricLink": "D'où viennent ces valeurs" + }, + "recordImport": { + "title": "Résultats d'analyses et médicaments depuis un tableur", + "description": "Importe des années de résultats ou une liste de médicaments depuis un fichier CSV.", + "detail": "Tu choisis quelle colonne contient quoi ; HealthLog vérifie chaque ligne d'abord et n'importe rien avant ta confirmation. Les lignes déjà présentes sont ignorées.", + "kind": { + "labs": "Résultats d'analyses", + "medications": "Médicaments" + }, + "sheetTitle": { + "labs": "Importer des résultats", + "medications": "Importer des médicaments" + }, + "sheetDescription": { + "labs": "Les marqueurs sont rattachés aux tiens, les unités converties quand la conversion est connue.", + "medications": "Nom, dose, heures quotidiennes, si besoin, début et fin. Les autres rythmes se règlent ensuite dans l'app." + }, + "file": "Fichier CSV", + "template": "Télécharger le modèle", + "mapping": "Quelle colonne contient quoi", + "required": "(obligatoire)", + "notInFile": "Absent du fichier", + "column": "Colonne {n}", + "delimiter": "Séparateur", + "delimiterOption": { + "comma": "Virgule", + "semicolon": "Point-virgule", + "tab": "Tabulation" + }, + "decimal": "Séparateur décimal", + "decimalOption": { + "dot": "Point (5.4)", + "comma": "Virgule (5,4)" + }, + "dateOrder": "Format de date", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (jour d'abord)", + "mdy": "03/01/2025 (mois d'abord)" + }, + "header": "La première ligne contient les noms de colonnes", + "reminders": "Activer les rappels pour les médicaments importés", + "timezoneNote": "Les heures sans fuseau sont lues dans le fuseau de ton compte. Une date sans heure reste ce jour du calendrier.", + "missing": "Encore à choisir : {fields}", + "check": "Vérifier le fichier", + "runIdle": "Importer", + "run": "Importer {count} lignes", + "close": "Fermer", + "summaryCheck": "{rows} lignes lues : {inserted} importables, {duplicates} déjà présentes, {failed} avec un problème.", + "summaryDone": "{inserted} lignes importées. Ignorées : {duplicates} déjà présentes et {failed} avec un problème.", + "markersMatched": "Rattachés à tes marqueurs :", + "markersCreated": "Nouveaux marqueurs :", + "line": "Ligne {line}", + "errorsTruncated": "Seuls les 500 premiers problèmes sont listés.", + "error": { + "tooLarge": "Le fichier dépasse 16 Mo.", + "rateLimited": "Trop d'importations au cours de la dernière heure. Réessaie plus tard.", + "invalid": "Le fichier ou le choix des colonnes n'a pas pu être lu.", + "failed": "L'importation n'a pas abouti. Réessaie." + }, + "reason": { + "missing_value": "Une cellule obligatoire est vide", + "invalid_number": "Ce n'est pas un nombre", + "ambiguous_number": "Le nombre se lit de deux façons ; vérifie le séparateur décimal", + "missing_unit": "Un nombre a besoin d'une unité", + "invalid_date": "Ce n'est pas une date au format choisi", + "invalid_time": "Ce n'est pas une heure", + "ambiguous_date": "La date se lit de deux façons ; utilise une année à quatre chiffres et le format choisi", + "future_date": "La date est dans le futur", + "text_too_long": "Le texte est trop long", + "unit_mismatch": "Cette unité ne se convertit pas dans celle du marqueur", + "invalid_reference_range": "La plage de référence est illisible", + "invalid_times": "Les heures sont illisibles (ex. 08:00 20:00)", + "missing_times": "Ajoute des heures de prise ou marque le médicament si besoin", + "invalid_yes_no": "Utilise oui ou non", + "as_needed_with_times": "Un médicament si besoin n'a pas d'heures fixes", + "end_before_start": "La fin précède le début", + "invalid_medication": "Le médicament ne peut pas être enregistré ainsi", + "oversized_row": "La ligne est trop longue ou trop large", + "row_limit": "Au-delà de la limite de lignes par importation", + "duplicate": "Déjà présente, ignorée" + }, + "field": { + "analyte": "Marqueur", + "value": "Résultat", + "unit": "Unité", + "date": "Date", + "time": "Heure", + "referenceRange": "Plage de référence", + "referenceLow": "Référence basse", + "referenceHigh": "Référence haute", + "panel": "Groupe", + "note": "Note", + "name": "Nom", + "dose": "Dose", + "times": "Heures", + "asNeeded": "Si besoin", + "startsOn": "Début", + "endsOn": "Fin" + } } } diff --git a/messages/it.json b/messages/it.json index 6f70120b2..8470a2db9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Limite clinico superiore", "clinicalLower": "Limite clinico inferiore" } + }, + "activityCalendar": { + "title": "Calendario attività", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Settimana", + "month": "Mese" + }, + "weekRange": "dal {start} al {end}", + "previous": "Periodo precedente", + "next": "Periodo successivo", + "metricLabel": "Colora i giorni per", + "metric": { + "steps": "Passi", + "activeMinutes": "Minuti attivi", + "load": "Carico di allenamento" + }, + "empty": "Ancora nessun passo, minuto attivo o allenamento in questo periodo.", + "loadError": "Impossibile caricare il calendario attività.", + "weekGoalsColumn": "Obiettivi settimanali", + "weekGoalsMet": "Tutti gli obiettivi settimanali raggiunti", + "weekGoalsNotMet": "Non tutti gli obiettivi settimanali raggiunti", + "cell": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti: {sports}" + }, + "level": { + "none": "Nessun dato", + "unrated": "Storico ancora insufficiente", + "low": "Molto sotto il tuo solito", + "below": "Sotto il tuo solito", + "usual": "Come al solito", + "above": "Sopra il tuo solito", + "high": "Molto sopra il tuo solito" + }, + "legend": "Rispetto alle tue 12 settimane precedenti:", + "noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.", + "summary": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti", + "load": "Carico di allenamento medio {value}", + "usual": "Il tuo solito fino a questo punto: {value}", + "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Allenamenti a settimana", + "activeMinutesPerWeek": "Minuti attivi a settimana", + "stepsPerWeek": "Passi a settimana", + "stepsPerMonth": "Passi al mese" + }, + "goalOf": "{value} di {target}", + "setGoals": "Imposta obiettivi", + "editGoals": "Modifica obiettivi", + "goalsSheet": { + "title": "Obiettivi di attività", + "description": "Lascia vuoto un campo per non avere un obiettivo.", + "none": "Nessun obiettivo", + "invalid": "Un numero intero da {min} a {max}.", + "saveError": "Impossibile salvare gli obiettivi." + }, + "crossLink": { + "title": "Calendario attività", + "body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.", + "cta": "Apri il calendario" + } + }, + "sourceProvenance": { + "title": "Da dove vengono i valori", + "description": "Quale fonte ha fornito un valore ogni giorno e quale usa la tua priorità delle fonti.", + "metric": "Misura", + "day": "Giorno", + "dayTitle": "Fonti di questo giorno", + "loadError": "Impossibile caricare le fonti.", + "dayEmpty": "Nessun valore di questa misura in questo giorno.", + "reason": { + "only_source": "{source} è l'unica fonte di questo giorno.", + "highest_on_ladder": "Viene usata {source}: tra le fonti di questo giorno è la più alta nella tua priorità delle fonti.", + "name_tiebreak": "Viene usata {source}: nessuna fonte di questo giorno è nella tua priorità, quindi ha deciso il nome.", + "not_ranked": "Questa misura non ha una priorità delle fonti: contano i valori di tutte le fonti." + }, + "pass": { + "lower_on_ladder": "Più in basso nella tua priorità delle fonti.", + "not_on_ladder": "Non è nella tua priorità delle fonti per questa misura.", + "name_tiebreak": "Stesso rango della fonte usata; ha deciso il nome." + }, + "used": "Usata", + "readings": "{count} misurazioni, dispositivi: {devices}", + "ladder": "Il tuo ordine per questa misura: {ladder}", + "utcNote": "La fonte viene scelta per giorno, da mezzanotte a mezzanotte UTC, come nei grafici.", + "openSettings": "Cambia l'ordine in Priorità delle fonti", + "compareTitle": "Confronta due fonti", + "range": "Periodo", + "rangeDays": "{days} giorni", + "compareNeedsTwo": "In questo periodo solo una fonte ha valori di questa misura.", + "side": { + "a": "Prima fonte", + "b": "Seconda fonte" + }, + "daysBoth": "Giorni con entrambe", + "meanOf": "Media di {source}", + "meanAbsDiff": "Differenza media", + "pickedDays": "Giorni usati (prima / seconda)", + "pickedSplit": "{a} / {b}", + "compareNote": "Le medie contano solo i giorni in cui entrambe le fonti hanno valori, per confrontare cose confrontabili. In grassetto il valore usato dai grafici.", + "settingsHint": "Per vedere cosa decide questo ordine giorno per giorno:", + "settingsHintLink": "Da dove vengono i valori", + "metricLink": "Da dove vengono questi valori" + }, + "recordImport": { + "title": "Esami di laboratorio e farmaci da un foglio di calcolo", + "description": "Porta dentro anni di esami o un elenco di farmaci da un file CSV.", + "detail": "Scegli tu quale colonna contiene cosa; HealthLog controlla ogni riga prima e non importa nulla finché non confermi. Le righe già presenti vengono saltate.", + "kind": { + "labs": "Esami di laboratorio", + "medications": "Farmaci" + }, + "sheetTitle": { + "labs": "Importa esami", + "medications": "Importa farmaci" + }, + "sheetDescription": { + "labs": "I marcatori vengono abbinati ai tuoi, le unità convertite quando la conversione è nota.", + "medications": "Nome, dose, orari giornalieri, al bisogno, inizio e fine. Gli altri ritmi si impostano poi nell'app." + }, + "file": "File CSV", + "template": "Scarica il modello", + "mapping": "Quale colonna contiene cosa", + "required": "(obbligatorio)", + "notInFile": "Non nel file", + "column": "Colonna {n}", + "delimiter": "Separatore", + "delimiterOption": { + "comma": "Virgola", + "semicolon": "Punto e virgola", + "tab": "Tabulazione" + }, + "decimal": "Separatore decimale", + "decimalOption": { + "dot": "Punto (5.4)", + "comma": "Virgola (5,4)" + }, + "dateOrder": "Formato data", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (prima il giorno)", + "mdy": "03/01/2025 (prima il mese)" + }, + "header": "La prima riga contiene i nomi delle colonne", + "reminders": "Attiva i promemoria per i farmaci importati", + "timezoneNote": "Gli orari senza fuso orario vengono letti nel fuso del tuo account. Una data senza orario resta quel giorno del calendario.", + "missing": "Ancora da scegliere: {fields}", + "check": "Controlla il file", + "runIdle": "Importa", + "run": "Importa {count} righe", + "close": "Chiudi", + "summaryCheck": "{rows} righe lette: {inserted} importabili, {duplicates} già presenti, {failed} con un problema.", + "summaryDone": "{inserted} righe importate. Saltate: {duplicates} già presenti e {failed} con un problema.", + "markersMatched": "Abbinati ai tuoi marcatori:", + "markersCreated": "Nuovi marcatori:", + "line": "Riga {line}", + "errorsTruncated": "Sono elencati solo i primi 500 problemi.", + "error": { + "tooLarge": "Il file supera i 16 MB.", + "rateLimited": "Troppe importazioni nell'ultima ora. Riprova più tardi.", + "invalid": "Impossibile leggere il file o la scelta delle colonne.", + "failed": "L'importazione non è riuscita. Riprova." + }, + "reason": { + "missing_value": "Una cella obbligatoria è vuota", + "invalid_number": "Non è un numero", + "ambiguous_number": "Il numero si legge in due modi; controlla il separatore decimale", + "missing_unit": "Un numero ha bisogno di un'unità", + "invalid_date": "Non è una data nel formato scelto", + "invalid_time": "Non è un orario", + "ambiguous_date": "La data si legge in due modi; usa un anno a quattro cifre e il formato scelto", + "future_date": "La data è nel futuro", + "text_too_long": "Il testo è troppo lungo", + "unit_mismatch": "Questa unità non si converte in quella del marcatore", + "invalid_reference_range": "L'intervallo di riferimento non è leggibile", + "invalid_times": "Gli orari non sono leggibili (es. 08:00 20:00)", + "missing_times": "Aggiungi gli orari di assunzione o segna il farmaco al bisogno", + "invalid_yes_no": "Usa sì o no", + "as_needed_with_times": "Un farmaco al bisogno non ha orari fissi", + "end_before_start": "La fine precede l'inizio", + "invalid_medication": "Il farmaco non si può salvare così", + "oversized_row": "La riga è troppo lunga o troppo larga", + "row_limit": "Oltre il limite di righe per importazione", + "duplicate": "Già presente, saltata" + }, + "field": { + "analyte": "Marcatore", + "value": "Risultato", + "unit": "Unità", + "date": "Data", + "time": "Ora", + "referenceRange": "Intervallo di riferimento", + "referenceLow": "Riferimento minimo", + "referenceHigh": "Riferimento massimo", + "panel": "Gruppo", + "note": "Nota", + "name": "Nome", + "dose": "Dose", + "times": "Orari", + "asNeeded": "Al bisogno", + "startsOn": "Inizio", + "endsOn": "Fine" + } } } diff --git a/messages/ko.json b/messages/ko.json index 74b652c3c..eae22171b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "임상 기준 상한", "clinicalLower": "임상 기준 하한" } + }, + "activityCalendar": { + "title": "활동 캘린더", + "periodLabel": "캘린더 기간", + "period": { + "week": "주", + "month": "월" + }, + "weekRange": "{start} ~ {end}", + "previous": "이전 기간", + "next": "다음 기간", + "metricLabel": "날짜 색상 기준", + "metric": { + "steps": "걸음 수", + "activeMinutes": "활동 시간(분)", + "load": "운동 부하" + }, + "empty": "이 기간에는 아직 걸음 수, 활동 시간, 운동 기록이 없습니다.", + "loadError": "활동 캘린더를 불러오지 못했습니다.", + "weekGoalsColumn": "주간 목표", + "weekGoalsMet": "주간 목표를 모두 달성함", + "weekGoalsNotMet": "주간 목표를 모두 달성하지는 못함", + "cell": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회: {sports}" + }, + "level": { + "none": "데이터 없음", + "unrated": "아직 기록이 충분하지 않음", + "low": "평소보다 훨씬 적음", + "below": "평소보다 적음", + "usual": "평소와 비슷함", + "above": "평소보다 많음", + "high": "평소보다 훨씬 많음" + }, + "legend": "이전 12주와 비교:", + "noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.", + "summary": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회", + "load": "평균 운동 부하 {value}", + "usual": "이 시점까지의 평소 값: {value}", + "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "주당 운동 횟수", + "activeMinutesPerWeek": "주당 활동 시간(분)", + "stepsPerWeek": "주당 걸음 수", + "stepsPerMonth": "월간 걸음 수" + }, + "goalOf": "{target} 중 {value}", + "setGoals": "목표 설정", + "editGoals": "목표 변경", + "goalsSheet": { + "title": "활동 목표", + "description": "목표를 두지 않으려면 칸을 비워 두세요.", + "none": "목표 없음", + "invalid": "{min}부터 {max}까지의 정수를 입력하세요.", + "saveError": "목표를 저장하지 못했습니다." + }, + "crossLink": { + "title": "활동 캘린더", + "body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.", + "cta": "캘린더 열기" + } + }, + "sourceProvenance": { + "title": "값의 출처", + "description": "날마다 어떤 소스가 값을 보냈고, 소스 우선순위가 어느 것을 쓰는지 보여 줍니다.", + "metric": "측정 항목", + "day": "날짜", + "dayTitle": "이 날의 소스", + "loadError": "소스를 불러오지 못했습니다.", + "dayEmpty": "이 날에는 이 항목의 값이 없습니다.", + "reason": { + "only_source": "이 날의 소스는 {source}뿐입니다.", + "highest_on_ladder": "{source}을(를) 사용합니다. 이 날의 소스 중 소스 우선순위가 가장 높습니다.", + "name_tiebreak": "{source}을(를) 사용합니다. 이 날의 소스가 모두 우선순위에 없어 이름으로 정했습니다.", + "not_ranked": "이 항목에는 소스 우선순위가 없어 모든 소스의 값이 반영됩니다." + }, + "pass": { + "lower_on_ladder": "소스 우선순위에서 더 아래에 있습니다.", + "not_on_ladder": "이 항목의 소스 우선순위에 없습니다.", + "name_tiebreak": "사용한 소스와 순위가 같아 이름으로 정했습니다." + }, + "used": "사용됨", + "readings": "측정 {count}회, 기기: {devices}", + "ladder": "이 항목의 순서: {ladder}", + "utcNote": "소스는 차트와 같은 방식으로 UTC 자정부터 자정까지 하루 단위로 선택됩니다.", + "openSettings": "소스 우선순위에서 순서 변경", + "compareTitle": "두 소스 비교", + "range": "기간", + "rangeDays": "{days}일", + "compareNeedsTwo": "이 기간에는 한 소스에만 이 항목의 값이 있습니다.", + "side": { + "a": "첫 번째 소스", + "b": "두 번째 소스" + }, + "daysBoth": "둘 다 있는 날", + "meanOf": "{source} 평균", + "meanAbsDiff": "평균 차이", + "pickedDays": "사용된 날 (첫 번째 / 두 번째)", + "pickedSplit": "{a} / {b}", + "compareNote": "평균은 두 소스 모두 값이 있는 날만 계산하여 같은 조건으로 비교합니다. 굵은 값이 차트에 쓰이는 값입니다.", + "settingsHint": "이 순서가 날마다 무엇을 정하는지 보려면:", + "settingsHintLink": "값의 출처", + "metricLink": "이 값의 출처" + }, + "recordImport": { + "title": "스프레드시트에서 검사 결과와 약 가져오기", + "description": "수년간의 검사 결과나 약 목록을 CSV 파일에서 가져옵니다.", + "detail": "어떤 열에 무엇이 있는지 직접 고르면, HealthLog가 모든 행을 먼저 확인하고 확인 전에는 아무것도 가져오지 않습니다. 이미 있는 행은 건너뜁니다.", + "kind": { + "labs": "검사 결과", + "medications": "약" + }, + "sheetTitle": { + "labs": "검사 결과 가져오기", + "medications": "약 가져오기" + }, + "sheetDescription": { + "labs": "마커는 내 마커에 연결되고, 변환 방법을 아는 경우 단위가 변환됩니다.", + "medications": "이름, 용량, 매일 복용 시간, 필요시, 시작일과 종료일. 다른 주기는 나중에 앱에서 설정합니다." + }, + "file": "CSV 파일", + "template": "템플릿 다운로드", + "mapping": "각 열의 내용", + "required": "(필수)", + "notInFile": "파일에 없음", + "column": "{n}열", + "delimiter": "구분자", + "delimiterOption": { + "comma": "쉼표", + "semicolon": "세미콜론", + "tab": "탭" + }, + "decimal": "소수점", + "decimalOption": { + "dot": "마침표 (5.4)", + "comma": "쉼표 (5,4)" + }, + "dateOrder": "날짜 형식", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (일 먼저)", + "mdy": "03/01/2025 (월 먼저)" + }, + "header": "첫 행에 열 이름이 있음", + "reminders": "가져온 약의 알림 켜기", + "timezoneNote": "시간대가 없는 시간은 계정의 시간대로 읽습니다. 시간이 없는 날짜는 그 날짜 그대로 저장됩니다.", + "missing": "아직 선택할 항목: {fields}", + "check": "파일 확인", + "runIdle": "가져오기", + "run": "{count}행 가져오기", + "close": "닫기", + "summaryCheck": "{rows}행 읽음: {inserted}행 가져오기 가능, {duplicates}행 이미 있음, {failed}행 문제 있음.", + "summaryDone": "{inserted}행을 가져왔습니다. 이미 있던 {duplicates}행과 문제가 있는 {failed}행은 건너뛰었습니다.", + "markersMatched": "내 마커에 연결됨:", + "markersCreated": "새 마커:", + "line": "{line}행", + "errorsTruncated": "처음 500개의 문제만 표시됩니다.", + "error": { + "tooLarge": "파일이 16MB보다 큽니다.", + "rateLimited": "지난 한 시간 동안 가져오기가 너무 많았습니다. 나중에 다시 시도하세요.", + "invalid": "파일이나 열 선택을 읽을 수 없습니다.", + "failed": "가져오기에 실패했습니다. 다시 시도하세요." + }, + "reason": { + "missing_value": "필수 칸이 비어 있음", + "invalid_number": "숫자가 아님", + "ambiguous_number": "숫자를 두 가지로 읽을 수 있음. 소수점을 확인하세요", + "missing_unit": "숫자에는 단위가 필요함", + "invalid_date": "선택한 형식의 날짜가 아님", + "invalid_time": "시간이 아님", + "ambiguous_date": "날짜를 두 가지로 읽을 수 있음. 네 자리 연도와 선택한 형식을 쓰세요", + "future_date": "날짜가 미래임", + "text_too_long": "텍스트가 너무 김", + "unit_mismatch": "이 단위는 마커의 단위로 변환할 수 없음", + "invalid_reference_range": "참고 범위를 읽을 수 없음", + "invalid_times": "시간을 읽을 수 없음 (예: 08:00 20:00)", + "missing_times": "복용 시간을 넣거나 필요시 약으로 표시하세요", + "invalid_yes_no": "예 또는 아니오로 쓰세요", + "as_needed_with_times": "필요시 약에는 정해진 시간이 없음", + "end_before_start": "종료일이 시작일보다 앞섬", + "invalid_medication": "이대로는 약을 저장할 수 없음", + "oversized_row": "행이 너무 길거나 넓음", + "row_limit": "한 번에 가져올 수 있는 행 수를 넘음", + "duplicate": "이미 있어 건너뜀" + }, + "field": { + "analyte": "마커", + "value": "결과", + "unit": "단위", + "date": "날짜", + "time": "시간", + "referenceRange": "참고 범위", + "referenceLow": "참고 하한", + "referenceHigh": "참고 상한", + "panel": "그룹", + "note": "메모", + "name": "이름", + "dose": "용량", + "times": "시간", + "asNeeded": "필요시", + "startsOn": "시작", + "endsOn": "종료" + } } } diff --git a/messages/nl.json b/messages/nl.json index 6d2ec61e7..21e3ec1df 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Klinische bovengrens", "clinicalLower": "Klinische ondergrens" } + }, + "activityCalendar": { + "title": "Activiteitenkalender", + "periodLabel": "Kalenderperiode", + "period": { + "week": "Week", + "month": "Maand" + }, + "weekRange": "{start} t/m {end}", + "previous": "Vorige periode", + "next": "Volgende periode", + "metricLabel": "Dagen kleuren op", + "metric": { + "steps": "Stappen", + "activeMinutes": "Actieve minuten", + "load": "Trainingsbelasting" + }, + "empty": "Nog geen stappen, actieve minuten of trainingen in deze periode.", + "loadError": "De activiteitenkalender kon niet worden geladen.", + "weekGoalsColumn": "Weekdoelen", + "weekGoalsMet": "Alle weekdoelen gehaald", + "weekGoalsNotMet": "Niet alle weekdoelen gehaald", + "cell": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen: {sports}" + }, + "level": { + "none": "Geen gegevens", + "unrated": "Nog te weinig geschiedenis", + "low": "Ver onder je gewone niveau", + "below": "Onder je gewone niveau", + "usual": "Ongeveer zoals gewoonlijk", + "above": "Boven je gewone niveau", + "high": "Ver boven je gewone niveau" + }, + "legend": "Vergeleken met je 12 weken daarvoor:", + "noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.", + "summary": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen", + "load": "Gemiddelde trainingsbelasting {value}", + "usual": "Gebruikelijk tot dit punt: {value}", + "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainingen per week", + "activeMinutesPerWeek": "Actieve minuten per week", + "stepsPerWeek": "Stappen per week", + "stepsPerMonth": "Stappen per maand" + }, + "goalOf": "{value} van {target}", + "setGoals": "Doelen instellen", + "editGoals": "Doelen wijzigen", + "goalsSheet": { + "title": "Activiteitsdoelen", + "description": "Laat een veld leeg als je geen doel wilt.", + "none": "Geen doel", + "invalid": "Een heel getal van {min} tot {max}.", + "saveError": "De doelen konden niet worden opgeslagen." + }, + "crossLink": { + "title": "Activiteitenkalender", + "body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.", + "cta": "Kalender openen" + } + }, + "sourceProvenance": { + "title": "Waar de waarden vandaan komen", + "description": "Welke bron elke dag een waarde leverde en welke je bronprioriteit gebruikt.", + "metric": "Meetwaarde", + "day": "Dag", + "dayTitle": "Bronnen op deze dag", + "loadError": "De bronnen konden niet worden geladen.", + "dayEmpty": "Geen waarden van deze meting op deze dag.", + "reason": { + "only_source": "{source} is op deze dag de enige bron.", + "highest_on_ladder": "{source} wordt gebruikt: van de bronnen van deze dag staat die het hoogst in je bronprioriteit.", + "name_tiebreak": "{source} wordt gebruikt: geen enkele bron van deze dag staat in je bronprioriteit, dus de naam besliste.", + "not_ranked": "Deze meting heeft geen bronprioriteit: de waarden van alle bronnen tellen mee." + }, + "pass": { + "lower_on_ladder": "Lager in je bronprioriteit.", + "not_on_ladder": "Staat voor deze meting niet in je bronprioriteit.", + "name_tiebreak": "Zelfde rang als de gebruikte bron; de naam besliste." + }, + "used": "Gebruikt", + "readings": "{count} metingen, apparaten: {devices}", + "ladder": "Je volgorde voor deze meting: {ladder}", + "utcNote": "De bron wordt per dag gekozen, van middernacht tot middernacht UTC, net als bij de grafieken.", + "openSettings": "Volgorde wijzigen in Bronprioriteit", + "compareTitle": "Twee bronnen vergelijken", + "range": "Periode", + "rangeDays": "{days} dagen", + "compareNeedsTwo": "In deze periode heeft maar één bron waarden van deze meting.", + "side": { + "a": "Eerste bron", + "b": "Tweede bron" + }, + "daysBoth": "Dagen met beide", + "meanOf": "Gemiddelde van {source}", + "meanAbsDiff": "Gemiddeld verschil", + "pickedDays": "Gebruikte dagen (eerste / tweede)", + "pickedSplit": "{a} / {b}", + "compareNote": "De gemiddelden tellen alleen dagen waarop beide bronnen waarden hebben, zodat je gelijke dagen vergelijkt. Vetgedrukt is de waarde die de grafieken gebruiken.", + "settingsHint": "Om te zien wat deze volgorde per dag beslist:", + "settingsHintLink": "Waar de waarden vandaan komen", + "metricLink": "Waar deze waarden vandaan komen" + }, + "recordImport": { + "title": "Labuitslagen en medicijnen uit een spreadsheet", + "description": "Haal jaren aan labuitslagen of een medicijnlijst binnen uit een CSV-bestand.", + "detail": "Jij kiest welke kolom wat bevat; HealthLog controleert eerst elke rij en importeert pas als je bevestigt. Rijen die er al zijn worden overgeslagen.", + "kind": { + "labs": "Labuitslagen", + "medications": "Medicijnen" + }, + "sheetTitle": { + "labs": "Labuitslagen importeren", + "medications": "Medicijnen importeren" + }, + "sheetDescription": { + "labs": "Markers worden aan de jouwe gekoppeld, eenheden omgerekend als de omrekening bekend is.", + "medications": "Naam, dosis, dagelijkse tijden, zo nodig, begin en einde. Andere ritmes stel je daarna in de app in." + }, + "file": "CSV-bestand", + "template": "Sjabloon downloaden", + "mapping": "Welke kolom bevat wat", + "required": "(verplicht)", + "notInFile": "Niet in het bestand", + "column": "Kolom {n}", + "delimiter": "Scheidingsteken", + "delimiterOption": { + "comma": "Komma", + "semicolon": "Puntkomma", + "tab": "Tab" + }, + "decimal": "Decimaalteken", + "decimalOption": { + "dot": "Punt (5.4)", + "comma": "Komma (5,4)" + }, + "dateOrder": "Datumnotatie", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (dag eerst)", + "mdy": "03/01/2025 (maand eerst)" + }, + "header": "De eerste rij bevat kolomnamen", + "reminders": "Herinneringen aanzetten voor geïmporteerde medicijnen", + "timezoneNote": "Tijden zonder tijdzone worden in de tijdzone van je account gelezen. Een datum zonder tijd blijft die kalenderdag.", + "missing": "Nog te kiezen: {fields}", + "check": "Bestand controleren", + "runIdle": "Importeren", + "run": "{count} rijen importeren", + "close": "Sluiten", + "summaryCheck": "{rows} rijen gelezen: {inserted} te importeren, {duplicates} al aanwezig, {failed} met een probleem.", + "summaryDone": "{inserted} rijen geïmporteerd. Overgeslagen: {duplicates} die er al waren en {failed} met een probleem.", + "markersMatched": "Gekoppeld aan je markers:", + "markersCreated": "Nieuwe markers:", + "line": "Regel {line}", + "errorsTruncated": "Alleen de eerste 500 problemen worden getoond.", + "error": { + "tooLarge": "Het bestand is groter dan 16 MB.", + "rateLimited": "Te veel imports in het afgelopen uur. Probeer het later opnieuw.", + "invalid": "Het bestand of de kolomkeuze kon niet worden gelezen.", + "failed": "Het importeren is niet gelukt. Probeer het opnieuw." + }, + "reason": { + "missing_value": "Een verplichte cel is leeg", + "invalid_number": "Geen getal", + "ambiguous_number": "Het getal is op twee manieren te lezen; controleer het decimaalteken", + "missing_unit": "Een getal heeft een eenheid nodig", + "invalid_date": "Geen datum in de gekozen notatie", + "invalid_time": "Geen tijd", + "ambiguous_date": "De datum is op twee manieren te lezen; gebruik een jaartal van vier cijfers en de gekozen notatie", + "future_date": "De datum ligt in de toekomst", + "text_too_long": "De tekst is te lang", + "unit_mismatch": "Deze eenheid is niet om te rekenen naar die van de marker", + "invalid_reference_range": "Het referentiebereik is niet te lezen", + "invalid_times": "De tijden zijn niet te lezen (bijv. 08:00 20:00)", + "missing_times": "Vul innametijden in of markeer het medicijn als zo nodig", + "invalid_yes_no": "Gebruik ja of nee", + "as_needed_with_times": "Een zo-nodig-medicijn heeft geen vaste tijden", + "end_before_start": "Het einde ligt voor het begin", + "invalid_medication": "Het medicijn kan zo niet worden opgeslagen", + "oversized_row": "De rij is te lang of te breed", + "row_limit": "Boven de rijlimiet voor één import", + "duplicate": "Al aanwezig, overgeslagen" + }, + "field": { + "analyte": "Marker", + "value": "Uitslag", + "unit": "Eenheid", + "date": "Datum", + "time": "Tijd", + "referenceRange": "Referentiebereik", + "referenceLow": "Referentie laag", + "referenceHigh": "Referentie hoog", + "panel": "Groep", + "note": "Notitie", + "name": "Naam", + "dose": "Dosis", + "times": "Tijden", + "asNeeded": "Zo nodig", + "startsOn": "Begin", + "endsOn": "Einde" + } } } diff --git a/messages/pl.json b/messages/pl.json index 7b84524e3..46cc9cb33 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13544,5 +13544,215 @@ "clinicalUpper": "Kliniczna górna granica", "clinicalLower": "Kliniczna dolna granica" } + }, + "activityCalendar": { + "title": "Kalendarz aktywności", + "periodLabel": "Zakres kalendarza", + "period": { + "week": "Tydzień", + "month": "Miesiąc" + }, + "weekRange": "od {start} do {end}", + "previous": "Poprzedni okres", + "next": "Następny okres", + "metricLabel": "Koloruj dni według", + "metric": { + "steps": "Kroki", + "activeMinutes": "Aktywne minuty", + "load": "Obciążenie treningowe" + }, + "empty": "W tym okresie nie ma jeszcze kroków, aktywnych minut ani treningów.", + "loadError": "Nie udało się wczytać kalendarza aktywności.", + "weekGoalsColumn": "Cele tygodniowe", + "weekGoalsMet": "Wszystkie cele tygodniowe osiągnięte", + "weekGoalsNotMet": "Nie wszystkie cele tygodniowe osiągnięte", + "cell": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value} ({sports})" + }, + "level": { + "none": "Brak danych", + "unrated": "Za mało historii", + "low": "Znacznie poniżej twojej normy", + "below": "Poniżej twojej normy", + "usual": "Mniej więcej jak zwykle", + "above": "Powyżej twojej normy", + "high": "Znacznie powyżej twojej normy" + }, + "legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:", + "noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.", + "summary": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value}", + "load": "Średnie obciążenie treningowe {value}", + "usual": "Twoja norma do tego momentu: {value}", + "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Treningi w tygodniu", + "activeMinutesPerWeek": "Aktywne minuty w tygodniu", + "stepsPerWeek": "Kroki w tygodniu", + "stepsPerMonth": "Kroki w miesiącu" + }, + "goalOf": "{value} z {target}", + "setGoals": "Ustaw cele", + "editGoals": "Zmień cele", + "goalsSheet": { + "title": "Cele aktywności", + "description": "Zostaw pole puste, jeśli nie chcesz celu.", + "none": "Brak celu", + "invalid": "Liczba całkowita od {min} do {max}.", + "saveError": "Nie udało się zapisać celów." + }, + "crossLink": { + "title": "Kalendarz aktywności", + "body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.", + "cta": "Otwórz kalendarz" + } + }, + "sourceProvenance": { + "title": "Skąd pochodzą wartości", + "description": "Które źródło dostarczyło wartość każdego dnia i którego używa twój priorytet źródeł.", + "metric": "Pomiar", + "day": "Dzień", + "dayTitle": "Źródła tego dnia", + "loadError": "Nie udało się wczytać źródeł.", + "dayEmpty": "Brak wartości tego pomiaru w tym dniu.", + "reason": { + "only_source": "{source} to jedyne źródło tego dnia.", + "highest_on_ladder": "Używane jest {source}: spośród źródeł tego dnia stoi najwyżej w twoim priorytecie źródeł.", + "name_tiebreak": "Używane jest {source}: żadnego ze źródeł tego dnia nie ma w twoim priorytecie, więc zdecydowała nazwa.", + "not_ranked": "Ten pomiar nie ma priorytetu źródeł: liczą się wartości ze wszystkich źródeł." + }, + "pass": { + "lower_on_ladder": "Niżej w twoim priorytecie źródeł.", + "not_on_ladder": "Nie ma go w twoim priorytecie źródeł dla tego pomiaru.", + "name_tiebreak": "Ta sama pozycja co użyte źródło; zdecydowała nazwa." + }, + "used": "Używane", + "readings": "Pomiary: {count}, urządzenia: {devices}", + "ladder": "Twoja kolejność dla tego pomiaru: {ladder}", + "utcNote": "Źródło jest wybierane na dzień, od północy do północy UTC, tak samo jak na wykresach.", + "openSettings": "Zmień kolejność w priorytecie źródeł", + "compareTitle": "Porównaj dwa źródła", + "range": "Okres", + "rangeDays": "{days} dni", + "compareNeedsTwo": "W tym okresie tylko jedno źródło ma wartości tego pomiaru.", + "side": { + "a": "Pierwsze źródło", + "b": "Drugie źródło" + }, + "daysBoth": "Dni z oboma", + "meanOf": "Średnia z {source}", + "meanAbsDiff": "Średnia różnica", + "pickedDays": "Użyte dni (pierwsze / drugie)", + "pickedSplit": "{a} / {b}", + "compareNote": "Średnie liczą tylko dni, w których oba źródła mają wartości, aby porównywać podobne dni. Pogrubiona jest wartość używana na wykresach.", + "settingsHint": "Aby zobaczyć, co ta kolejność rozstrzyga dzień po dniu:", + "settingsHintLink": "Skąd pochodzą wartości", + "metricLink": "Skąd pochodzą te wartości" + }, + "recordImport": { + "title": "Wyniki badań i leki z arkusza kalkulacyjnego", + "description": "Zaimportuj wyniki badań z wielu lat lub listę leków z pliku CSV.", + "detail": "Ty wybierasz, która kolumna zawiera co; HealthLog najpierw sprawdza każdy wiersz i niczego nie importuje bez twojego potwierdzenia. Wiersze, które już są, zostaną pominięte.", + "kind": { + "labs": "Wyniki badań", + "medications": "Leki" + }, + "sheetTitle": { + "labs": "Importuj wyniki badań", + "medications": "Importuj leki" + }, + "sheetDescription": { + "labs": "Markery są przypisywane do twoich, jednostki przeliczane, gdy przeliczenie jest znane.", + "medications": "Nazwa, dawka, codzienne godziny, w razie potrzeby, początek i koniec. Inne rytmy ustawisz potem w aplikacji." + }, + "file": "Plik CSV", + "template": "Pobierz szablon", + "mapping": "Która kolumna zawiera co", + "required": "(wymagane)", + "notInFile": "Brak w pliku", + "column": "Kolumna {n}", + "delimiter": "Separator", + "delimiterOption": { + "comma": "Przecinek", + "semicolon": "Średnik", + "tab": "Tabulator" + }, + "decimal": "Separator dziesiętny", + "decimalOption": { + "dot": "Kropka (5.4)", + "comma": "Przecinek (5,4)" + }, + "dateOrder": "Format daty", + "dateOrderOption": { + "iso": "2025-03-01", + "dmy": "01.03.2025 (najpierw dzień)", + "mdy": "03/01/2025 (najpierw miesiąc)" + }, + "header": "Pierwszy wiersz zawiera nazwy kolumn", + "reminders": "Włącz przypomnienia dla zaimportowanych leków", + "timezoneNote": "Godziny bez strefy czasowej są czytane w strefie twojego konta. Data bez godziny pozostaje tym dniem kalendarza.", + "missing": "Do wyboru: {fields}", + "check": "Sprawdź plik", + "runIdle": "Importuj", + "run": "Importuj wiersze: {count}", + "close": "Zamknij", + "summaryCheck": "Odczytane wiersze: {rows}. Do zaimportowania: {inserted}, już są: {duplicates}, z problemem: {failed}.", + "summaryDone": "Zaimportowane wiersze: {inserted}. Pominięte: {duplicates}, które już były, i {failed} z problemem.", + "markersMatched": "Przypisane do twoich markerów:", + "markersCreated": "Nowe markery:", + "line": "Wiersz {line}", + "errorsTruncated": "Wymieniono tylko pierwsze 500 problemów.", + "error": { + "tooLarge": "Plik jest większy niż 16 MB.", + "rateLimited": "Za dużo importów w ostatniej godzinie. Spróbuj później.", + "invalid": "Nie udało się odczytać pliku lub wyboru kolumn.", + "failed": "Import się nie powiódł. Spróbuj ponownie." + }, + "reason": { + "missing_value": "Wymagana komórka jest pusta", + "invalid_number": "To nie jest liczba", + "ambiguous_number": "Liczbę można odczytać na dwa sposoby; sprawdź separator dziesiętny", + "missing_unit": "Liczba wymaga jednostki", + "invalid_date": "To nie jest data w wybranym formacie", + "invalid_time": "To nie jest godzina", + "ambiguous_date": "Datę można odczytać na dwa sposoby; użyj czterocyfrowego roku i wybranego formatu", + "future_date": "Data jest w przyszłości", + "text_too_long": "Tekst jest za długi", + "unit_mismatch": "Tej jednostki nie da się przeliczyć na jednostkę markera", + "invalid_reference_range": "Nie można odczytać zakresu referencyjnego", + "invalid_times": "Nie można odczytać godzin (np. 08:00 20:00)", + "missing_times": "Dodaj godziny przyjmowania albo oznacz lek jako w razie potrzeby", + "invalid_yes_no": "Użyj tak lub nie", + "as_needed_with_times": "Lek w razie potrzeby nie ma stałych godzin", + "end_before_start": "Koniec jest przed początkiem", + "invalid_medication": "Nie można tak zapisać leku", + "oversized_row": "Wiersz jest za długi lub za szeroki", + "row_limit": "Ponad limit wierszy dla jednego importu", + "duplicate": "Już jest, pominięto" + }, + "field": { + "analyte": "Marker", + "value": "Wynik", + "unit": "Jednostka", + "date": "Data", + "time": "Godzina", + "referenceRange": "Zakres referencyjny", + "referenceLow": "Dolna granica", + "referenceHigh": "Górna granica", + "panel": "Grupa", + "note": "Notatka", + "name": "Nazwa", + "dose": "Dawka", + "times": "Godziny", + "asNeeded": "W razie potrzeby", + "startsOn": "Początek", + "endsOn": "Koniec" + } } } diff --git a/prisma/migrations/0470_user_activity_goals/migration.sql b/prisma/migrations/0470_user_activity_goals/migration.sql new file mode 100644 index 000000000..49d25a9d1 --- /dev/null +++ b/prisma/migrations/0470_user_activity_goals/migration.sql @@ -0,0 +1,9 @@ +-- Optional per-user activity goals for the activity calendar: workouts, +-- active minutes and steps per week, steps per month. +-- +-- Nullable, no default, no backfill: null reads as "no goal set", which is +-- every account's state today. +-- +-- Reversibility: dropping the column removes the goals; nothing else reads it. + +ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 203479c7a..cf97a4241 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -392,6 +392,12 @@ model User { /// Settings > System through `/api/settings/preview-features`. /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. previewFeaturesJson Json? @map("preview_features_json") + /// Optional activity goals the person set for themselves: workouts, active + /// minutes and steps per week, steps per month. Null or an absent key = no + /// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through + /// `/api/activity/goals`, read by the activity calendar + /// (`/api/activity/calendar`), which reports progress against each goal set. + activityGoalsJson Json? @map("activity_goals_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 6469bcfa4..5b91d591b 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -96,6 +96,9 @@ const MAY_READ_EVERY_ROW: Record = { // as stored, schedule and switch included. "lib/export/full-backup-payload.ts": "backup carries the stored rows", "lib/export/restore-backup.ts": "restore writes the stored rows back", + // v1.44 — the CSV import writes new medications and reads only their names + // and doses to skip duplicates; it derives nothing due. + "lib/import/medications-csv.ts": "imports new rows, derives nothing due", // v1.43 — the units a taken dose consumed, resolved per slot exactly as // the inventory does. It reads only doses already recorded as taken. "lib/medications/ingredients/recompute.ts": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9b5d0704a..33b0c3305 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1077,6 +1077,18 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", }, + "app/api/measurements/provenance/route.ts": { + domain: "measurements", + why: "Which source delivered a metric on one UTC day and which one the record's source priority picks, with the reason. Read-only aggregates of the resolved user's readings; no AI, no note text.", + }, + "app/api/measurements/provenance/compare/route.ts": { + domain: "measurements", + why: "Two sources of one metric side by side over at most a year of UTC days, with the source the ladder picked each day. Read-only aggregates of the resolved user's readings; no AI, no note text.", + }, + "app/api/activity/calendar/route.ts": { + domain: "measurements", + why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.", + }, "app/api/cycle/cycles/route.ts": { domain: "cycle", why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", @@ -2178,8 +2190,11 @@ const ACTOR_ROUTES: Record = { * * v1.44 -- the module list layout and the preview-feature switch are the * person's own presentation and join the actor list. 268 -> 270. + * + * v1.44 -- the activity calendar read, the activity goals and the source + * provenance reads join the list. 270 -> 273. */ -const FROZEN_ENTRY_COUNT = 270; +const FROZEN_ENTRY_COUNT = 273; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 858a950ab..361c8afa2 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record< // the two status grids used to spell the tint at every call site and now // name a tone instead, so the marker lives here once. "src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 }, + // v1.44: the activity calendar's per-week tick for "every weekly goal met", + // a state of a row the person reads, not the outcome of a write. + "src/components/activity/activity-calendar-card.tsx": { "text-success": 1 }, // v1.42: the readiness inventory's "carries" status icon, one arm of the // carries / thin / empty tone table, beside its spoken status label. "src/components/timeline/readiness-inventory.tsx": { "text-success": 1 }, diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 84d221c5b..7574edc0c 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -46,6 +46,9 @@ const IDENTITY_OWNER: Record = { // The doctor report's per-sport block (v1.44): sums sessions, so a session // two sources reported must count once, as it does on the list. "lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts", + // The activity calendar counts workouts per day; a session two devices + // recorded must count once there, as it does in the list. + "lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts", // The only indirection in the set. The Coach block is handed rows it does not // read itself; the snapshot builder fetches them in parallel with every other // block's data. Recording the owner here rather than assuming the call site diff --git a/src/__tests__/write-rate-limit-guard.test.ts b/src/__tests__/write-rate-limit-guard.test.ts index 3b3b3559b..8498633f5 100644 --- a/src/__tests__/write-rate-limit-guard.test.ts +++ b/src/__tests__/write-rate-limit-guard.test.ts @@ -53,6 +53,9 @@ const LIMITING_HELPERS: Record = { "lib/withings/webhook-handler.ts", "lib/whoop/webhook-handler.ts", ], + // v1.44 — the lab and medication CSV imports: a preview bucket for a dry + // run, the shared `import:` bucket for the import itself. + handleRecordImport: ["lib/import/record-import-route.ts"], }; /** Mutating routes with no limit, each with the reason it needs none. */ diff --git a/src/app/api/activity/__tests__/routes.test.ts b/src/app/api/activity/__tests__/routes.test.ts new file mode 100644 index 000000000..e189a3cea --- /dev/null +++ b/src/app/api/activity/__tests__/routes.test.ts @@ -0,0 +1,168 @@ +/** + * `/api/activity/calendar` and `/api/activity/goals` (D1036). + * + * The goals: a PATCH replaces only what it names, `null` clears, an unknown + * key or a goal out of bounds is a 422 and writes nothing, and clearing the + * last goal returns the column to NULL rather than storing an empty object. + * The calendar: the module gate answers before any read, a future or malformed + * day is refused, and the reader is asked for exactly the window the period + * needs and never past today. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + findUnique: vi.fn(), + update: vi.fn(), + readActivityDays: vi.fn(), + requireModuleEnabled: vi.fn(), + auditLog: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } }, + toJson: (v: unknown) => v, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: mocks.requireModuleEnabled, +})); +vi.mock("@/lib/activity/calendar-read", () => ({ + readActivityDays: mocks.readActivityDays, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +import { Prisma } from "@/generated/prisma/client"; +import { GET as calendarGet } from "../calendar/route"; +import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route"; + +type Handler = (req: NextRequest) => Promise; + +function patch(body: unknown) { + return (goalsPatch as unknown as Handler)( + new NextRequest("http://localhost/api/activity/goals", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function calendar(query: string) { + return (calendarGet as unknown as Handler)( + new NextRequest(`http://localhost/api/activity/calendar${query}`), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.findUnique.mockResolvedValue({ + activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 }, + }); + mocks.update.mockResolvedValue({}); + mocks.requireModuleEnabled.mockResolvedValue({ enabled: true }); + mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null }); +}); + +describe("activity goals", () => { + it("reads every goal, unset ones as null", async () => { + const res = await (goalsGet as unknown as () => Promise)(); + const body = await res.json(); + expect(body.data).toEqual({ + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: 50000, + stepsPerMonth: null, + }); + }); + + it("changes what the body names and keeps the rest", async () => { + const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 }); + expect(res.status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + activityGoalsJson: { + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: null, + stepsPerMonth: 250000, + }, + }, + }); + expect(mocks.auditLog).toHaveBeenCalledWith( + "activity.goals.update", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("stores NULL once the last goal is cleared", async () => { + await patch({ workoutsPerWeek: null, stepsPerWeek: null }); + expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe( + Prisma.DbNull, + ); + }); + + it.each([ + [{ caloriesPerWeek: 3000 }], + [{ workoutsPerWeek: 0 }], + [{ stepsPerWeek: 2.5 }], + [{ stepsPerMonth: "200000" }], + ])("refuses %j without writing", async (body) => { + const res = await patch(body); + expect(res.status).toBe(422); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); + +describe("activity calendar", () => { + it("answers the module gate before reading anything", async () => { + mocks.requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + }); + const res = await calendar("?period=month"); + expect(res.status).toBe(403); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])( + "refuses %s", + async (q) => { + const res = await calendar(q); + expect(res.status).toBe(422); + }, + ); + + it("refuses a day after today", async () => { + const res = await calendar("?date=2999-01-01"); + expect(res.status).toBe(422); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it("reads the baseline window before the period and nothing past the grid", async () => { + const res = await calendar("?period=week&date=2025-03-12"); + expect(res.status).toBe(200); + const args = mocks.readActivityDays.mock.calls[0][0]; + // Week of Monday 2025-03-10; 84 days before it. + expect(args.fromKey).toBe("2024-12-16"); + expect(args.toKey).toBe("2025-03-16"); + expect(args.tz).toBe("Europe/Berlin"); + const body = await res.json(); + expect(body.data.start).toBe("2025-03-10"); + expect(body.data.goals.workoutsPerWeek).toBe(3); + expect(body.data.days).toHaveLength(7); + }); +}); diff --git a/src/app/api/activity/calendar/route.ts b/src/app/api/activity/calendar/route.ts new file mode 100644 index 000000000..9ee482f07 --- /dev/null +++ b/src/app/api/activity/calendar/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/activity/calendar` — a week or a month of activity against the + * person's own normal, with their weekly and monthly goals (D1036). + * + * Computed server-side, without AI, from the stored readings: steps (the + * canonical-source day totals the steps chart draws), exercise minutes, + * workouts after the cross-source collapse, and a training-load score when a + * device or HealthLog provides one. What "usual" means is in + * `src/lib/activity/calendar-math.ts`; it is always the person's own history, + * never a population norm. + * + * Owned by the Workouts module, like the page that shows it: a switched-off + * module answers 403 here as `/api/workouts` does. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + buildActivityCalendar, + periodBounds, +} from "@/lib/activity/calendar-math"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar"; +import { parseActivityGoals } from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +/** How far back the calendar pages: ten years, the series route's ceiling. */ +const MAX_DAYS_BACK = 3650 - 200; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "activity.calendar.read" } }); + + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + + const params = new URL(request.url).searchParams; + const parsed = activityCalendarQuerySchema.safeParse({ + period: params.get("period") ?? undefined, + date: params.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const tz = await resolveUserTimezone(user.id); + const now = new Date(); + const today = userDayKey(now, tz); + const anchor = parsed.data.date ?? today; + if (anchor > today) { + return apiError("The calendar cannot show a day after today", 422, { + errorCode: "activity.calendar.future_date", + }); + } + if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) { + return apiError("The calendar reaches back ten years", 422, { + errorCode: "activity.calendar.too_old", + }); + } + + const bounds = periodBounds(parsed.data.period, anchor); + const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today; + const [{ days, loadType }, goalRow] = await Promise.all([ + readActivityDays({ + userId: user.id, + tz, + fromKey: bounds.readStart, + toKey: lastRead, + now, + includeWorkouts: true, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { activityGoalsJson: true }, + }), + ]); + + const calendar = buildActivityCalendar({ + bounds, + today, + input: days, + goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null), + }); + + annotate({ + meta: { + activity_calendar_period: bounds.period, + activity_calendar_days_read: days.length, + activity_calendar_load_type: loadType ?? "none", + }, + }); + return apiSuccess({ ...calendar, timezone: tz, loadType }); +}); diff --git a/src/app/api/activity/goals/route.ts b/src/app/api/activity/goals/route.ts new file mode 100644 index 000000000..2a2ef4c5d --- /dev/null +++ b/src/app/api/activity/goals/route.ts @@ -0,0 +1,90 @@ +/** + * GET / PATCH the caller's activity goals (D1036). + * + * Every goal is optional and set by the person; there is no default goal. + * PATCH replaces the goals the body names (`null` clears one) and keeps the + * rest; an unknown key or an out-of-bounds goal is refused with the + * multi-issue 422. The goals live on their own `User` column + * (`activity_goals_json`) and the activity calendar reads them. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { Prisma } from "@/generated/prisma/client"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + ACTIVITY_GOAL_KEYS, + activityGoalsSchema, + hasNoActivityGoals, + mergeActivityGoals, + parseActivityGoals, + type ActivityGoals, +} from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +async function readGoals(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { activityGoalsJson: true }, + }); + return parseActivityGoals(row?.activityGoalsJson ?? null); +} + +function setKeys(goals: ActivityGoals): string[] { + return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null); +} + +export const GET = apiHandler(async () => { + // Owner-only, like the write: a delegate sees the goals where they matter, + // inside the calendar, which carries them. + const { user } = await requireAuth(); + const goals = await readGoals(user.id); + annotate({ + action: { name: "activity.goals.read" }, + meta: { set: setKeys(goals) }, + }); + return apiSuccess(goals); +}); + +export const PATCH = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = activityGoalsSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "activity.goals.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = mergeActivityGoals(await readGoals(user.id), parsed.data); + await prisma.user.update({ + where: { id: user.id }, + data: { + activityGoalsJson: hasNoActivityGoals(next) + ? Prisma.DbNull + : toJson(next), + }, + }); + + await auditLog("activity.goals.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { set: setKeys(next) }, + }); + annotate({ + action: { name: "activity.goals.update" }, + meta: { set: setKeys(next) }, + }); + return apiSuccess(next); +}); diff --git a/src/app/api/import/__tests__/record-csv-routes.test.ts b/src/app/api/import/__tests__/record-csv-routes.test.ts new file mode 100644 index 000000000..2c3ec0294 --- /dev/null +++ b/src/app/api/import/__tests__/record-csv-routes.test.ts @@ -0,0 +1,165 @@ +/** + * The request side of `/api/import/labs-csv` and `/api/import/medications-csv` + * (#1215): module gate first, a separate bucket for dry runs, the content + * type, the byte cap while streaming, the mapping refused before anything is + * read, and an audit row only for a real import. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), + requireModuleEnabled: vi.fn(), + importLabsCsv: vi.fn(), + importMedicationsCsv: vi.fn(), + auditLog: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: mocks.requireModuleEnabled, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/import/labs-csv", () => ({ + importLabsCsv: mocks.importLabsCsv, +})); +vi.mock("@/lib/import/medications-csv", () => ({ + importMedicationsCsv: mocks.importMedicationsCsv, +})); + +import { CsvTooLargeError } from "@/lib/import/csv-stream"; +import { POST as labsPost } from "../labs-csv/route"; +import { POST as medsPost } from "../medications-csv/route"; + +type Handler = (req: NextRequest) => Promise; +const RESULT = { + kind: "labs", + dryRun: false, + rows: 2, + valid: 2, + inserted: 2, + duplicates: 0, + failed: 0, + errors: [], + errorsTruncated: false, + preview: [], +}; + +function post( + handler: unknown, + query: string, + body = "a,b\n1,2\n", + type = "text/csv", +) { + return (handler as Handler)( + new NextRequest(`http://localhost/api/import/x?${query}`, { + method: "POST", + headers: { "content-type": type }, + body, + }), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.requireModuleEnabled.mockResolvedValue({ enabled: true }); + mocks.importLabsCsv.mockResolvedValue(RESULT); + mocks.importMedicationsCsv.mockResolvedValue({ + ...RESULT, + kind: "medications", + }); +}); + +const LABS = "analyte=0&value=1&unit=2&date=3"; +const MEDS = "name=0&dose=1×=2"; + +describe("record CSV import routes", () => { + it("answers the module gate before reading the file", async () => { + mocks.requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + }); + expect((await post(labsPost, LABS)).status).toBe(403); + expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs"); + expect(mocks.importLabsCsv).not.toHaveBeenCalled(); + }); + + it("refuses a mapping without the required columns, or with a shared column", async () => { + expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422); + expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422); + expect((await post(medsPost, "name=0")).status).toBe(422); + expect(mocks.importLabsCsv).not.toHaveBeenCalled(); + }); + + it("spends the preview bucket on a dry run and the import bucket otherwise", async () => { + await post(labsPost, `${LABS}&dryRun=1`); + expect(mocks.checkRateLimit).toHaveBeenLastCalledWith( + "import-preview:user-1", + 30, + 3_600_000, + ); + expect(mocks.auditLog).not.toHaveBeenCalled(); + await post(medsPost, MEDS); + expect(mocks.checkRateLimit).toHaveBeenLastCalledWith( + "import:user-1", + 5, + 3_600_000, + ); + expect(mocks.auditLog).toHaveBeenCalledWith( + "import.medications.csv", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("answers 429 when the bucket is spent", async () => { + mocks.checkRateLimit.mockResolvedValue({ allowed: false }); + expect((await post(labsPost, LABS)).status).toBe(429); + }); + + it("refuses a body that is not CSV", async () => { + expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe( + 415, + ); + }); + + it("turns the streaming cap into a 413", async () => { + mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1)); + expect((await post(labsPost, LABS)).status).toBe(413); + }); + + it("calls a file without data rows empty", async () => { + mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 }); + expect((await post(labsPost, LABS)).status).toBe(422); + }); + + it("hands the parsed mapping and the account zone to the importer", async () => { + const res = await post( + labsPost, + `${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`, + ); + expect(res.status).toBe(200); + const args = mocks.importLabsCsv.mock.calls[0][0]; + expect(args.tz).toBe("Europe/Berlin"); + expect(args.query).toMatchObject({ + analyte: 0, + value: 1, + unit: 2, + date: 3, + decimal: "comma", + dateOrder: "dmy", + delimiter: "semicolon", + dryRun: false, + header: true, + }); + expect(args.stream).toBeInstanceOf(ReadableStream); + }); +}); diff --git a/src/app/api/import/labs-csv/route.ts b/src/app/api/import/labs-csv/route.ts new file mode 100644 index 000000000..b06ee3e34 --- /dev/null +++ b/src/app/api/import/labs-csv/route.ts @@ -0,0 +1,25 @@ +/** + * `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215). + * + * Body: the CSV file, streamed. Query: the column mapping and the file's + * conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports + * what would happen without writing; the real import writes in chunks, skips + * readings the record already holds and reports every refused row by line. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { importLabsCsv } from "@/lib/import/labs-csv"; +import { handleRecordImport } from "@/lib/import/record-import-route"; +import { labsCsvQuerySchema } from "@/lib/validations/record-import"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => + handleRecordImport(request, { + kind: "labs", + module: "labs", + schema: labsCsvQuerySchema, + run: (args) => importLabsCsv(args), + }), +); diff --git a/src/app/api/import/medications-csv/route.ts b/src/app/api/import/medications-csv/route.ts new file mode 100644 index 000000000..324a3181e --- /dev/null +++ b/src/app/api/import/medications-csv/route.ts @@ -0,0 +1,25 @@ +/** + * `POST /api/import/medications-csv` — medications from a spreadsheet (#1215). + * + * Body: the CSV file, streamed. Query: the column mapping and the file's + * conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily + * times, as-needed and a start and end date; reminders stay off unless the + * query turns them on. `dryRun=1` checks without writing. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { importMedicationsCsv } from "@/lib/import/medications-csv"; +import { handleRecordImport } from "@/lib/import/record-import-route"; +import { medicationsCsvQuerySchema } from "@/lib/validations/record-import"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => + handleRecordImport(request, { + kind: "medications", + module: "medications", + schema: medicationsCsvQuerySchema, + run: (args) => importMedicationsCsv(args), + }), +); diff --git a/src/app/api/measurements/provenance/compare/route.ts b/src/app/api/measurements/provenance/compare/route.ts new file mode 100644 index 000000000..c889e7504 --- /dev/null +++ b/src/app/api/measurements/provenance/compare/route.ts @@ -0,0 +1,42 @@ +/** + * `GET /api/measurements/provenance/compare` — two sources of one metric side + * by side over up to a year of UTC days, with the source the ladder picked + * each day (#1204). Read-only; one aggregate query, at most one row per day + * and source. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readSourceComparison } from "@/lib/provenance/source-provenance"; +import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "measurement.provenance.compare" } }); + const params = new URL(request.url).searchParams; + const parsed = sourceComparisonQuerySchema.safeParse({ + type: params.get("type") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + a: params.get("a") ?? undefined, + b: params.get("b") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const result = await readSourceComparison({ + userId: user.id, + ...parsed.data, + }); + annotate({ + meta: { + provenance_type: result.type, + provenance_days: result.days.length, + provenance_sources: result.sources.length, + }, + }); + return apiSuccess(result); +}); diff --git a/src/app/api/measurements/provenance/route.ts b/src/app/api/measurements/provenance/route.ts new file mode 100644 index 000000000..23eac328d --- /dev/null +++ b/src/app/api/measurements/provenance/route.ts @@ -0,0 +1,40 @@ +/** + * `GET /api/measurements/provenance` — which sources delivered a metric on a + * day, which one the source-priority ladder picked, and why (#1204, #1108, + * #576). Read-only; the explanation comes from the same rank SQL the charts + * and summaries pick with (`src/lib/provenance/source-provenance.ts`). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readDayProvenance } from "@/lib/provenance/source-provenance"; +import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "measurement.provenance.day" } }); + const params = new URL(request.url).searchParams; + const parsed = dayProvenanceQuerySchema.safeParse({ + type: params.get("type") ?? undefined, + date: params.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const result = await readDayProvenance({ + userId: user.id, + type: parsed.data.type, + date: parsed.data.date, + }); + annotate({ + meta: { + provenance_type: result.type, + provenance_sources: result.sources.length, + provenance_pick_reason: result.pickReason ?? "none", + }, + }); + return apiSuccess(result); +}); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 43186b0a4..df2d6d797 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react"; @@ -226,6 +227,8 @@ export default function InsightsBlutdruckPage() { slug="blood-pressure" icon={} /> + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index f99f89681..9f41dbe02 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { Gauge, Heart } from "lucide-react"; @@ -211,6 +212,8 @@ export default function InsightsPulsPage() { cta={t("insights.vo2Max.cardioLinkCta")} /> ) : null} + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index d7a1d99b2..8b7659f36 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { Moon } from "lucide-react"; @@ -153,6 +154,8 @@ export default function InsightsSchlafPage() { + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/sources/page.tsx b/src/app/insights/sources/page.tsx new file mode 100644 index 000000000..d59fc7286 --- /dev/null +++ b/src/app/insights/sources/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; + +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { SourceProvenanceView } from "@/components/provenance/source-provenance-view"; +import { useTranslations } from "@/lib/i18n/context"; +import { isProvenanceType } from "@/lib/provenance/ranked-types"; + +/** + * v1.44 — `/insights/sources` (#1204, #1108, #576). + * + * Where a metric's values come from: per day, every source that delivered + * one and the one the source priority picked, with the reason; and two + * sources side by side over a range. Reached from a metric's page and from + * Settings > Source priority; `?type=` and `?date=` open it on a metric and a + * day. Read-only: the order that decides the pick is edited in Settings. + */ +function SourcesPageBody() { + const params = useSearchParams(); + const type = params.get("type"); + const date = params.get("date"); + return ( + + ); +} + +export default function InsightsSourcesPage() { + const { t } = useTranslations(); + return ( + + + + + + ); +} diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx index 57783168e..a562c4baf 100644 --- a/src/app/insights/steps/page.tsx +++ b/src/app/insights/steps/page.tsx @@ -1,8 +1,11 @@ "use client"; -import { Footprints } from "lucide-react"; +import { CalendarDays, Footprints } from "lucide-react"; +import { CrossLinkCard } from "@/components/insights/cross-link-card"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.12 — `/insights/steps`. @@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * user having step data. Reuses the generic HealthKitMetricPage scaffold; * empty-state carries no manual-entry CTA — the series arrives from Apple * Health or Withings sync. + * + * v1.44 — links across to the activity calendar on the workouts page, which + * shows the same day totals as a week or month against the person's usual. + * The link follows the Workouts module: switched off, it is not offered. */ export default function InsightsStepsPage() { + const { t } = useTranslations(); + const calendarVisible = useSurfaceVisible("insights-page:workouts"); return ( + ) : null + } /> ); } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c91f9b1b9..c5ac87fff 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { Scale } from "lucide-react"; @@ -222,6 +223,8 @@ export default function InsightsGewichtPage() { slug="weight" icon={} /> + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index c307d65af..40e58529d 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { translateOrNull } from "@/lib/i18n/translate-code"; import { EmptyState } from "@/components/ui/empty-state"; +import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card"; /** * v1.4.32 — `/insights/workouts`. @@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() { {showLog ? ( ) : null} + {/* The activity calendar (D1036): steps, active minutes and workouts + against the person's own usual. It stands above the list and draws + from steps alone too, so an account without workouts still sees + its routine. */} + {/* No `` here: workouts are session records, not a MeasurementType series, so the period-over-period range read has nothing to aggregate. */} diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx new file mode 100644 index 000000000..a005c664f --- /dev/null +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -0,0 +1,157 @@ +/** + * `` draws what the server computed (SSR markup, like + * the other component suites). The arithmetic is pinned in + * `src/lib/activity/__tests__/calendar-math.test.ts`; this suite pins that + * the card shows each day's level, the workouts, the summary against the + * usual and the goals, and that a shared record gets no goal editor. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + buildActivityCalendar, + periodBounds, + type ActivityDayInput, +} from "@/lib/activity/calendar-math"; +import { shiftDateKey } from "@/lib/tz/format"; +import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals"; + +const state: { data: unknown; shared: boolean } = { + data: undefined, + shared: false, +}; + +vi.mock("@/hooks/use-activity-calendar", () => ({ + useActivityCalendar: () => ({ + data: state.data, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSaveActivityGoals: () => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: state.shared }), +})); + +import { ActivityCalendarCard } from "../activity-calendar-card"; +import { parseGoalField } from "../activity-goals-sheet"; + +function calendar() { + const today = "2026-10-21"; + const bounds = periodBounds("month", today); + const input: ActivityDayInput[] = []; + for (let i = 1; i <= 200; i++) { + input.push({ + date: shiftDateKey(bounds.start, -i), + steps: 6000 + (i % 4) * 500, + exerciseMinutes: 30, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); + } + input.push({ + date: "2026-10-05", + steps: 20000, + exerciseMinutes: 90, + workoutMinutes: 75, + workoutCount: 1, + workoutsBySport: { running: 1 }, + load: null, + }); + return { + ...buildActivityCalendar({ + bounds, + today, + input, + goals: { + ...NO_ACTIVITY_GOALS, + workoutsPerWeek: 1, + stepsPerMonth: 100000, + }, + }), + timezone: "Europe/Berlin", + loadType: null, + }; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ActivityCalendarCard", () => { + it("shows a loading frame before the data arrives", () => { + state.data = undefined; + expect(render()).toContain('data-slot="activity-calendar-loading"'); + }); + + it("draws every day of the grid with its level", () => { + state.data = calendar(); + const html = render(); + const days = html.match(/data-slot="activity-calendar-day"/g) ?? []; + expect(days.length).toBe(35); + expect(html).toMatch(/data-date="2026-10-05" data-level="high"/); + expect(html).toMatch(/data-date="2026-10-30" data-level="none"/); + // The accessible name carries the figures and the level in words. + expect(html).toContain("20,000 steps"); + expect(html).toContain("Far above your usual"); + }); + + it("summarises the period against the usual and lists the sports", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain('data-metric="steps"'); + expect(html).toContain("Your usual by this point:"); + expect(html).toContain("Running: 1"); + }); + + it("shows goal progress and a week tick column", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain("Steps per month"); + expect(html).toContain( + 'data-slot="activity-calendar-week-goal" data-met="true"', + ); + expect(html).toContain('data-slot="activity-calendar-set-goals"'); + }); + + it("offers no goal editor inside somebody else's record", () => { + state.data = calendar(); + state.shared = true; + const html = render(); + state.shared = false; + expect(html).not.toContain('data-slot="activity-calendar-set-goals"'); + }); +}); + +describe("goal field parsing", () => { + it("blank clears, digits within bounds pass, anything else is refused", () => { + expect(parseGoalField("workoutsPerWeek", " ")).toEqual({ + ok: true, + value: null, + }); + expect(parseGoalField("workoutsPerWeek", "4")).toEqual({ + ok: true, + value: 4, + }); + expect(parseGoalField("workoutsPerWeek", "0")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "10.000")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "70000")).toEqual({ + ok: true, + value: 70000, + }); + expect(parseGoalField("stepsPerMonth", "99999999")).toEqual({ ok: false }); + }); +}); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx new file mode 100644 index 000000000..7e9eef083 --- /dev/null +++ b/src/components/activity/activity-calendar-card.tsx @@ -0,0 +1,521 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + CalendarDays, + Check, + ChevronLeft, + ChevronRight, + Target, +} from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Progress } from "@/components/ui/progress"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ScorePill } from "@/components/ui/score-pill"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { ActivityGoalsSheet } from "@/components/activity/activity-goals-sheet"; +import { useActivityCalendar } from "@/hooks/use-activity-calendar"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { translateOrNull } from "@/lib/i18n/translate-code"; +import { cn } from "@/lib/utils"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; + +type Period = "week" | "month"; +type RatedMetric = "steps" | "activeMinutes" | "load"; +type CalendarDay = ActivityCalendarResponse["days"][number]; +type DayLevel = CalendarDay["level"]["steps"]; + +/** + * One hue, rising intensity: the calendar shows more and less than usual, + * not good and bad, so no status colour takes part. The bar is a graphic + * (3:1 floor), the day number beside it stays foreground text. + */ +const LEVEL_BAR: Record = { + none: "bg-transparent", + unrated: "bg-muted-foreground/60", + low: "bg-chart-2/20", + below: "bg-chart-2/40", + usual: "bg-chart-2/60", + above: "bg-chart-2/80", + high: "bg-chart-2", +}; + +const LEGEND_LEVELS: DayLevel[] = ["low", "below", "usual", "above", "high"]; + +/** 2024-01-01 is a Monday: seven UTC days from it name the week's columns. */ +function weekdayLabels(locale: string): string[] { + const fmt = new Intl.DateTimeFormat(locale, { + weekday: "narrow", + timeZone: "UTC", + }); + return Array.from({ length: 7 }, (_, i) => + fmt.format(new Date(Date.UTC(2024, 0, 1 + i))), + ); +} + +function dateOfKey(key: string): Date { + return new Date(`${key}T12:00:00.000Z`); +} + +/** + * The activity calendar (D1036): a week or a month of steps, active minutes, + * workouts and training load against the person's own usual, with the goals + * they set. Every figure comes from `/api/activity/calendar`; this component + * draws, it does not compute. + */ +export function ActivityCalendarCard() { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const capabilities = useRecordCapabilities(); + const [period, setPeriod] = useState("month"); + const [anchor, setAnchor] = useState(null); + const [metricChoice, setMetricChoice] = useState(null); + const [goalsOpen, setGoalsOpen] = useState(false); + // Each opening mounts a fresh sheet, so its draft starts from the stored + // goals; keyed on the count rather than on `open`, so closing keeps the + // sheet mounted for its exit animation. + const [goalsOpenCount, setGoalsOpenCount] = useState(0); + const query = useActivityCalendar(period, anchor); + const data = query.data; + const weekdays = useMemo(() => weekdayLabels(locale), [locale]); + // A calendar day key names the same date everywhere: read it in UTC, where + // its noon anchor sits, never in the viewer's zone. + const dayNameFormat = useMemo( + () => + new Intl.DateTimeFormat(locale, { + weekday: "long", + day: "numeric", + month: "long", + timeZone: "UTC", + }), + [locale], + ); + + const ratedOptions = useMemo(() => { + if (!data) return [] as RatedMetric[]; + return (["steps", "activeMinutes", "load"] as const).filter( + (m) => data.available[m], + ); + }, [data]); + const metric: RatedMetric | null = + metricChoice && ratedOptions.includes(metricChoice) + ? metricChoice + : (ratedOptions[0] ?? null); + + const periodLabel = data + ? period === "month" + ? new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(dateOfKey(data.start)) + : t("activityCalendar.weekRange", { + start: fmt.dateShortSmartCalendar(data.start), + end: fmt.dateShortSmartCalendar(data.end), + }) + : ""; + + const formatMetric = (m: RatedMetric | "workouts", value: number) => + m === "load" ? fmt.number(value, 1) : fmt.integer(Math.round(value)); + + const sportLabel = (sport: string) => + translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport; + + const weeklyGoalsSet = + data !== undefined && + (data.goals.workoutsPerWeek !== null || + data.goals.activeMinutesPerWeek !== null || + data.goals.stepsPerWeek !== null); + + const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`); + + const dayLabel = (day: CalendarDay) => { + const parts = [dayNameFormat.format(dateOfKey(day.date))]; + if (day.future) return parts.join(", "); + if (day.steps !== null) + parts.push( + t("activityCalendar.cell.steps", { value: fmt.integer(day.steps) }), + ); + if (day.activeMinutes !== null) + parts.push( + t("activityCalendar.cell.activeMinutes", { + value: fmt.integer(day.activeMinutes), + }), + ); + if (day.workoutCount > 0) + parts.push( + t("activityCalendar.cell.workouts", { + value: fmt.integer(day.workoutCount), + sports: Object.keys(day.workoutsBySport).map(sportLabel).join(", "), + }), + ); + if (metric) parts.push(levelText(day.level[metric])); + return parts.join(", "); + }; + + const rows: CalendarDay[][] = []; + if (data) { + for (let i = 0; i < data.days.length; i += 7) { + rows.push(data.days.slice(i, i + 7)); + } + } + + return ( + + + { + setPeriod(next); + setAnchor(data ? data.start : null); + }} + options={[ + { value: "week", label: t("activityCalendar.period.week") }, + { value: "month", label: t("activityCalendar.period.month") }, + ]} + /> + } + /> + + + {query.isError ? ( + query.refetch()} + /> + ) : !data ? ( +
+ + +
+ ) : ( + <> +
+ +

+ {periodLabel} +

+ +
+ + {ratedOptions.length > 1 && metric ? ( + ({ + value: m, + label: t(`activityCalendar.metric.${m}`), + }))} + /> + ) : null} + + {!data.available.steps && + !data.available.activeMinutes && + !data.available.workouts && + !data.available.load ? ( + + ) : ( +
+
+ {weekdays.map((label, i) => ( + + {label} + + ))} + {weeklyGoalsSet && period === "month" ? ( + + + {t("activityCalendar.weekGoalsColumn")} + + + ) : null} +
+ {rows.map((week, rowIndex) => { + const weekRow = data.weeks[rowIndex]; + const allMet = + weekRow && weekRow.goals.length > 0 + ? weekRow.goals.every((g) => g.met) + : null; + return ( +
+ {week.map((day) => { + const level = metric ? day.level[metric] : "none"; + const value = + metric && !day.future ? day[metric] : null; + return ( +
+ +
+ ); + })} + {weeklyGoalsSet && period === "month" ? ( + + {allMet ? ( + + ) : allMet === false ? ( + + {t("activityCalendar.weekGoalsNotMet")} + + ) : null} + + ) : null} +
+ ); + })} +
+ )} + + {metric ? ( +
+ {t("activityCalendar.legend")} + {LEGEND_LEVELS.map((level) => ( + + + ))} + {data.baseline[metric] === null ? ( + + {t("activityCalendar.noBaseline")} + + ) : null} +
+ ) : null} + +
    + {(["steps", "activeMinutes", "workouts", "load"] as const) + .filter((m) => data.available[m]) + .map((m) => { + const s = data.summary[m]; + const delta = + s.value !== null && s.usualToDate + ? (s.value - s.usualToDate) / s.usualToDate + : null; + return ( +
  • +
    +

    + {t(`activityCalendar.summary.${m}`, { + value: + s.value === null ? "–" : formatMetric(m, s.value), + })} +

    +

    + {s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })} +

    +
    + {delta !== null ? ( + + {`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`} + + ) : null} +
  • + ); + })} +
+ + {data.sports.length > 0 ? ( +
+ {data.sports.map((s) => ( + + {t("activityCalendar.sportCount", { + sport: sportLabel(s.sportType), + count: fmt.integer(s.count), + })} + + ))} +
+ ) : null} + +
+ {data.goalProgress.map((g) => ( +
+
+ + + + {t("activityCalendar.goalOf", { + value: fmt.integer(Math.round(g.value)), + target: fmt.integer(g.target), + })} + +
+ +
+ ))} + {!capabilities.inSharedRecord ? ( + { + setGoalsOpenCount((n) => n + 1); + setGoalsOpen(true); + }} + > + {data.goalProgress.length > 0 || weeklyGoalsSet + ? t("activityCalendar.editGoals") + : t("activityCalendar.setGoals")} + + ) : null} +
+ + )} +
+ {data && !capabilities.inSharedRecord ? ( + + ) : null} +
+ ); +} diff --git a/src/components/activity/activity-goals-sheet.tsx b/src/components/activity/activity-goals-sheet.tsx new file mode 100644 index 000000000..d5f1082b2 --- /dev/null +++ b/src/components/activity/activity-goals-sheet.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useSaveActivityGoals } from "@/hooks/use-activity-calendar"; +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +type Goals = Record; +type Draft = Record; + +function toDraft(goals: Goals): Draft { + return Object.fromEntries( + ACTIVITY_GOAL_KEYS.map((k) => [ + k, + goals[k] === null ? "" : String(goals[k]), + ]), + ) as Draft; +} + +/** + * Parse one field. Blank clears the goal; anything else must be a whole + * number inside the bounds the server enforces. Digits only, so a grouping + * separator typed by habit ("10.000") is refused rather than misread. + */ +export function parseGoalField( + key: ActivityGoalKey, + raw: string, +): { ok: true; value: number | null } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: true, value: null }; + if (!/^\d+$/.test(trimmed)) return { ok: false }; + const value = Number(trimmed); + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + if (value < min || value > max) return { ok: false }; + return { ok: true, value }; +} + +/** + * Set or clear the activity goals. Every goal is optional; an empty field is + * no goal. There is no suggested number: the goal is the person's own. + * + * The draft starts from `goals` when the sheet mounts; the caller remounts it + * on every open (a `key`), so a reopened sheet never shows a stale draft. + */ +export function ActivityGoalsSheet({ + open, + onOpenChange, + goals, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + goals: Goals; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const save = useSaveActivityGoals(); + const [draft, setDraft] = useState(() => toDraft(goals)); + const [invalid, setInvalid] = useState([]); + + const submit = () => { + const bad: ActivityGoalKey[] = []; + const patch: Partial = {}; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = parseGoalField(key, draft[key]); + if (!parsed.ok) bad.push(key); + else patch[key] = parsed.value; + } + setInvalid(bad); + if (bad.length > 0) return; + save.mutate(patch, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + {t("common.save")} + + } + > +
{ + e.preventDefault(); + submit(); + }} + > + {ACTIVITY_GOAL_KEYS.map((key) => { + const id = `activity-goal-${key}`; + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + const isInvalid = invalid.includes(key); + return ( +
+ + + setDraft((d) => ({ ...d, [key]: e.target.value })) + } + /> + {isInvalid ? ( + + ) : null} +
+ ); + })} + {save.isError ? ( +

+ {t("activityCalendar.goalsSheet.saveError")} +

+ ) : null} +
+
+ ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 22129a95e..a59a2baa3 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; @@ -523,6 +524,9 @@ export function HealthKitMetricPage({ {targetSummarySlug ? ( ) : null} + {/* v1.44 (#1204) — where the values come from, for a metric the + source priority decides for. Nothing for any other metric. */} + {/* v1.17.0 — page-specific extra block (blood glucose: the clinical panel). Mounted on the data-bearing branch only. */} {afterChart} diff --git a/src/components/provenance/__tests__/source-provenance-view.test.tsx b/src/components/provenance/__tests__/source-provenance-view.test.tsx new file mode 100644 index 000000000..a951201f5 --- /dev/null +++ b/src/components/provenance/__tests__/source-provenance-view.test.tsx @@ -0,0 +1,176 @@ +/** + * `` draws the server's answer (SSR markup): the day's + * sources with the used one marked and the reason in words, the person's + * ladder, and the side-by-side table with the used value in bold. The link + * from a metric page appears only for a metric the ladder decides for. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const state: { day: unknown; compare: unknown } = { + day: undefined, + compare: undefined, +}; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); +vi.mock("@/hooks/use-source-provenance", () => ({ + useDayProvenance: () => ({ + data: state.day, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSourceComparison: () => ({ + data: state.compare, + isError: false, + error: null, + refetch: vi.fn(), + }), +})); + +import { SourceProvenanceView } from "../source-provenance-view"; +import { SourceProvenanceLink } from "../source-provenance-link"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +const DAY = { + type: "SLEEP_DURATION", + date: "2026-10-01", + aggregation: "sum", + ranked: true, + metricKey: "sleep", + ladder: ["WHOOP", "OURA"], + picked: "WHOOP", + pickReason: "highest_on_ladder", + sources: [ + { + source: "WHOOP", + rank: 0, + readings: 1, + value: 420, + min: 420, + max: 420, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["band"], + pass: null, + }, + { + source: "OURA", + rank: 1, + readings: 3, + value: 391, + min: 30, + max: 301, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["ring"], + pass: "lower_on_ladder", + }, + ], +}; + +const COMPARE = { + type: "SLEEP_DURATION", + from: "2026-09-02", + to: "2026-10-01", + aggregation: "sum", + ranked: true, + ladder: ["WHOOP", "OURA"], + unit: "min", + sources: [ + { source: "OURA", days: 30 }, + { source: "WHOOP", days: 20 }, + ], + a: "OURA", + b: "WHOOP", + days: [{ date: "2026-10-01", a: 391, b: 420, picked: "WHOOP" }], + summary: { + daysA: 30, + daysB: 20, + daysBoth: 20, + meanA: 395, + meanB: 440, + meanDifference: 45, + meanAbsoluteDifference: 45, + pickedA: 10, + pickedB: 20, + }, +}; + +describe("SourceProvenanceView", () => { + it("names the used source, why, and why the other was not used", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-source="WHOOP" data-picked="true"'); + expect(html).toContain('data-source="OURA" data-picked="false"'); + expect(html).toContain("stands highest in your source priority"); + expect(html).toContain("Lower in your source priority."); + expect(html).toContain("Your order for this metric: WHOOP, Oura"); + expect(html).toContain('href="/settings/sources"'); + }); + + it("lines up two sources and marks the used value", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-compare-row"'); + expect(html).toMatch(/font-semibold tabular-nums">420/); + expect(html).toContain("10 / 20"); + }); + + it("says when there is nothing to compare", () => { + state.day = { ...DAY, sources: [], picked: null, pickReason: null }; + state.compare = { + ...COMPARE, + sources: [{ source: "OURA", days: 3 }], + b: null, + }; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-day-empty"'); + expect(html).toContain('data-slot="provenance-compare-empty"'); + }); +}); + +describe("SourceProvenanceLink", () => { + it("links ranked metrics and stays away from the rest", () => { + expect(render()).toContain( + "/insights/sources?type=ACTIVITY_STEPS", + ); + expect(render()).toBe(""); + }); +}); diff --git a/src/components/provenance/source-provenance-link.tsx b/src/components/provenance/source-provenance-link.tsx new file mode 100644 index 000000000..6017748dd --- /dev/null +++ b/src/components/provenance/source-provenance-link.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; + +import { InlineAction } from "@/components/ui/inline-action"; +import { useTranslations } from "@/lib/i18n/context"; +import { isProvenanceType } from "@/lib/provenance/ranked-types"; + +/** + * "Where these values come from": a small link from a metric's page to the + * provenance view, offered for the metrics the source priority decides for. + * Renders nothing for any other type. + */ +export function SourceProvenanceLink({ type }: { type: string }) { + const { t } = useTranslations(); + if (!isProvenanceType(type)) return null; + return ( + + + {t("sourceProvenance.metricLink")} + + + ); +} diff --git a/src/components/provenance/source-provenance-view.tsx b/src/components/provenance/source-provenance-view.tsx new file mode 100644 index 000000000..e5a1dffb0 --- /dev/null +++ b/src/components/provenance/source-provenance-view.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { GitCompareArrows, Layers } from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { DateField } from "@/components/ui/date-field"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { + useDayProvenance, + useSourceComparison, +} from "@/hooks/use-source-provenance"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; +import { + PROVENANCE_TYPES, + type ProvenanceType, +} from "@/lib/provenance/ranked-types"; +import { shiftDateKey } from "@/lib/tz/format"; + +type RangeDays = "30" | "90" | "365"; + +/** + * Today as a UTC day key: the source ladder picks per UTC day, so the page + * names its days the way the pick cuts them. + */ +function utcToday(): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the ladder pick is per UTC day, and this page explains that pick + return new Date().toISOString().slice(0, 10); +} + +/** + * The provenance view (#1204, #1108, #576): for one metric, which sources + * delivered values on a day, which one the source priority picked and why, + * and two sources side by side over a range. Read-only; the settings that + * decide the pick stay in Settings > Source priority. + */ +export function SourceProvenanceView({ + initialType, + initialDate, +}: { + initialType: ProvenanceType; + initialDate: string | null; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [type, setType] = useState(initialType); + const [date, setDate] = useState(initialDate ?? utcToday()); + const [range, setRange] = useState("30"); + const [a, setA] = useState(undefined); + const [b, setB] = useState(undefined); + const to = utcToday(); + const from = shiftDateKey(to, -(Number(range) - 1)); + + const day = useDayProvenance(type, date); + const compare = useSourceComparison({ type, from, to, a, b }); + + const sourceLabel = (source: string) => { + const key = + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS + ]; + return key ? t(key) : source; + }; + const typeLabel = (value: ProvenanceType) => + t(MEASUREMENT_TYPE_LABEL_KEYS[value]); + const sortedTypes = [...PROVENANCE_TYPES].sort((x, y) => + typeLabel(x).localeCompare(typeLabel(y)), + ); + + const formatValue = (value: number | null, aggregation: "sum" | "mean") => + value === null + ? "–" + : aggregation === "sum" + ? fmt.integer(Math.round(value)) + : fmt.number(value, 1); + + return ( +
+
+
+ + { + setType(e.target.value as ProvenanceType); + setA(undefined); + setB(undefined); + }} + > + {sortedTypes.map((value) => ( + + ))} + +
+
+ + value && setDate(value)} + /> +
+
+ + + + + + + {day.isError ? ( + day.refetch()} + /> + ) : !day.data ? ( + + ) : day.data.sources.length === 0 ? ( + + ) : ( + <> +

+ {day.data.pickReason === "not_ranked" + ? t("sourceProvenance.reason.not_ranked") + : t( + `sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`, + { + source: sourceLabel(day.data.picked ?? ""), + }, + )} +

+
    + {day.data.sources.map((row) => ( +
  • +
    +

    + {sourceLabel(row.source)} + {row.source === day.data?.picked ? ( + {t("sourceProvenance.used")} + ) : null} +

    +

    + {t("sourceProvenance.readings", { + count: fmt.integer(row.readings), + devices: row.devices.join(", "), + })} +

    + {row.pass ? ( +

    + {t(`sourceProvenance.pass.${row.pass}`)} +

    + ) : null} +
    +

    + {formatValue(row.value, day.data?.aggregation ?? "mean")}{" "} + + {row.unit} + +

    +
  • + ))} +
+ {day.data.ranked ? ( +

+ {t("sourceProvenance.ladder", { + ladder: day.data.ladder.map(sourceLabel).join(", "), + })} +

+ ) : null} +

+ {t("sourceProvenance.utcNote")} +

+ + )} + + + {t("sourceProvenance.openSettings")} + + +
+
+ + + + + } + /> + + + {compare.isError ? ( + compare.refetch()} + /> + ) : !compare.data ? ( + + ) : compare.data.sources.length < 2 ? ( + + ) : ( + <> +
+ {(["a", "b"] as const).map((side) => ( +
+ + + side === "a" + ? setA(e.target.value) + : setB(e.target.value) + } + > + {compare.data?.sources.map((s) => ( + + ))} + +
+ ))} +
+
+
+ {t("sourceProvenance.daysBoth")} +
+
+ {fmt.integer(compare.data.summary.daysBoth)} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.a ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanA, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.b ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanB, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanAbsDiff")} +
+
+ {formatValue( + compare.data.summary.meanAbsoluteDifference, + "mean", + )}{" "} + + {compare.data.unit} + +
+
+ {t("sourceProvenance.pickedDays")} +
+
+ {t("sourceProvenance.pickedSplit", { + a: fmt.integer(compare.data.summary.pickedA), + b: fmt.integer(compare.data.summary.pickedB), + })} +
+
+
+ + + + + + + + + + {[...compare.data.days].reverse().map((d) => ( + + + {(["a", "b"] as const).map((side) => { + const source = compare.data?.[side]; + const used = source !== null && d.picked === source; + return ( + + ); + })} + + ))} + +
+ {t("sourceProvenance.day")} + + {sourceLabel(compare.data.a ?? "")} + + {sourceLabel(compare.data.b ?? "")} +
+ setDate(d.date)} + > + {fmt.dateShortSmartCalendar(d.date)} + + + {formatValue( + d[side], + compare.data?.aggregation ?? "mean", + )} + {used ? ( + + {" "} + {t("sourceProvenance.used")} + + ) : null} +
+
+

+ {t("sourceProvenance.compareNote")} +

+ + )} +
+
+
+ ); +} diff --git a/src/components/settings/import-panel/index.tsx b/src/components/settings/import-panel/index.tsx index 7f6ebde9e..9f39cf527 100644 --- a/src/components/settings/import-panel/index.tsx +++ b/src/components/settings/import-panel/index.tsx @@ -14,6 +14,9 @@ * (file/paste, client-side parseability guard). * 3. CSV import — `csv-import-card.tsx` * (file/paste, dry-run preview). + * 4. Lab results and medications from a spreadsheet — + * `record-csv-import-card.tsx` (v1.44, #1215; column mapping, check, + * import). * * Both controls surface rate-limit (429) and error states cleanly and * announce progress to assistive tech via `aria-live` regions. @@ -28,6 +31,7 @@ import { HealthConnectImportCard } from "./health-connect-import-card"; import { JsonImportCard } from "./json-import-card"; import { CsvImportCard } from "./csv-import-card"; import { DoseHistoryImportCard } from "./dose-history-import-card"; +import { RecordCsvImportCard } from "./record-csv-import-card"; export { EXAMPLE_IMPORT, @@ -55,6 +59,7 @@ export function ImportPanel() { + ); diff --git a/src/components/settings/import-panel/record-csv-import-card.tsx b/src/components/settings/import-panel/record-csv-import-card.tsx new file mode 100644 index 000000000..2613f2b0a --- /dev/null +++ b/src/components/settings/import-panel/record-csv-import-card.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { apiFetch, ApiError } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + RECORD_IMPORT_TEMPLATES, + fieldsFor, + guessFormat, + guessMapping, + readHead, + type FieldKey, + type FileHead, + type RecordImportKind, +} from "@/lib/import/csv-mapping"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +import { ImportCardShell } from "./import-card-shell"; + +/** The head of the file the mapping screen reads: enough for six rows. */ +const HEAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 16 * 1024 * 1024; + +type Delimiter = "comma" | "semicolon" | "tab"; + +function delimiterName(d: string): Delimiter { + return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma"; +} + +function downloadTemplate(kind: RecordImportKind) { + const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-${kind}-template.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Settings > Export & Import: lab results and medications from a spreadsheet + * (#1215). The card opens a sheet that reads the head of the file, suggests + * the column for each field, lets the person correct every choice, checks + * the whole file on the server (nothing written) and then imports it. + */ +export function RecordCsvImportCard() { + const { t } = useTranslations(); + const [open, setOpen] = useState(null); + const [session, setSession] = useState(0); + return ( + +

{t("recordImport.detail")}

+ + {(["labs", "medications"] as const).map((kind) => ( + + ))} + + {open ? ( + !next && setOpen(null)} + /> + ) : null} +
+ ); +} + +function RecordCsvImportSheet({ + kind, + onOpenChange, +}: { + kind: RecordImportKind; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const fileId = useId(); + const [file, setFile] = useState(null); + const [head, setHead] = useState(null); + const [mapping, setMapping] = useState>>({}); + const [delimiter, setDelimiter] = useState("comma"); + const [decimal, setDecimal] = useState<"dot" | "comma">("dot"); + const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso"); + const [header, setHeader] = useState(true); + const [reminders, setReminders] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [check, setCheck] = useState(null); + const [done, setDone] = useState(null); + + const fields = fieldsFor(kind); + const columns = useMemo(() => { + if (!head) return [] as string[]; + const width = Math.max(...head.rows.map((r) => r.length), 0); + return Array.from({ length: width }, (_, i) => + header && head.rows[0]?.[i]?.trim() + ? head.rows[0][i].trim() + : t("recordImport.column", { n: i + 1 }), + ); + }, [head, header, t]); + const missing = fields.filter( + (f) => f.required && mapping[f.key] === undefined, + ); + + async function onFile(e: React.ChangeEvent) { + const picked = e.target.files?.[0] ?? null; + e.target.value = ""; + setCheck(null); + setDone(null); + setError(null); + if (!picked) return; + if (picked.size > MAX_FILE_BYTES) { + setError(t("recordImport.error.tooLarge")); + return; + } + const text = await picked.slice(0, HEAD_BYTES).text(); + const parsedHead = readHead(text); + const guessed = guessMapping(parsedHead.rows[0] ?? [], kind); + const format = guessFormat( + parsedHead, + guessed[kind === "labs" ? "date" : "startsOn"], + ); + setFile(picked); + setHead(parsedHead); + setMapping(guessed); + setDelimiter(delimiterName(parsedHead.delimiter)); + setDecimal(format.decimal); + setDateOrder(format.dateOrder); + } + + async function send(dryRun: boolean) { + if (!file) return; + setBusy(true); + setError(null); + const params = new URLSearchParams({ + dryRun: dryRun ? "1" : "0", + header: header ? "1" : "0", + delimiter, + decimal, + dateOrder, + }); + if (kind === "medications") params.set("reminders", reminders ? "1" : "0"); + for (const f of fields) { + const index = mapping[f.key]; + if (index !== undefined) params.set(f.key, String(index)); + } + try { + const result = await apiFetch( + `/api/import/${kind}-csv?${params}`, + { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: file, + // A large file is read and written row by row; give it time. + signal: AbortSignal.timeout(5 * 60_000), + }, + ); + if (dryRun) setCheck(result); + else setDone(result); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + setError( + status === 429 + ? t("recordImport.error.rateLimited") + : status === 413 + ? t("recordImport.error.tooLarge") + : status === 422 + ? t("recordImport.error.invalid") + : t("recordImport.error.failed"), + ); + } finally { + setBusy(false); + } + } + + const result = done ?? check; + + return ( + onOpenChange(false)} + > + {t("recordImport.close")} + + ) : ( +
+ + +
+ ) + } + > +
+
+ + + +
+ + {head ? ( + <> +
+ + {t("recordImport.mapping")} + + {fields.map((f) => ( +
+ + { + setCheck(null); + const v = e.target.value; + setMapping((m) => { + const next = { ...m }; + // One column feeds one field: taking it here frees it elsewhere. + for (const k of Object.keys(next) as FieldKey[]) { + if (v !== "" && next[k] === Number(v)) delete next[k]; + } + if (v === "") delete next[f.key]; + else next[f.key] = Number(v); + return next; + }); + }} + > + + {columns.map((name, i) => ( + + ))} + +
+ ))} +
+ +
+
+ + { + setCheck(null); + setDelimiter(e.target.value as Delimiter); + }} + > + {(["comma", "semicolon", "tab"] as const).map((d) => ( + + ))} + +
+
+ + { + setCheck(null); + setDecimal(e.target.value as "dot" | "comma"); + }} + > + + + +
+
+ + { + setCheck(null); + setDateOrder(e.target.value as "iso" | "dmy" | "mdy"); + }} + > + {(["iso", "dmy", "mdy"] as const).map((o) => ( + + ))} + +
+
+ +
+ + { + setCheck(null); + setHeader(v); + }} + /> +
+ {kind === "medications" ? ( +
+ + { + setCheck(null); + setReminders(v); + }} + /> +
+ ) : null} +

+ {t("recordImport.timezoneNote")} +

+ +
+ + + {head.rows.map((row, r) => ( + + {row.map((c, i) => ( + + ))} + + ))} + +
+ {c} +
+
+ + ) : null} + + {missing.length > 0 && head ? ( +

+ {t("recordImport.missing", { + fields: missing + .map((f) => t(`recordImport.field.${f.key}`)) + .join(", "), + })} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {result ? ( +
+

+ {done + ? t("recordImport.summaryDone", { + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + }) + : t("recordImport.summaryCheck", { + rows: fmt.integer(result.rows), + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + })} +

+ {result.markers && + (result.markers.created.length > 0 || + result.markers.matched.length > 0) ? ( +
+ {result.markers.matched.length > 0 ? ( +

+ + {t("recordImport.markersMatched")}{" "} + + {result.markers.matched + .map((m) => + m.name === m.marker + ? m.name + : `${m.name} (${m.marker})`, + ) + .join(", ")} +

+ ) : null} + {result.markers.created.length > 0 ? ( +

+ + {t("recordImport.markersCreated")}{" "} + + {result.markers.created.join(", ")} +

+ ) : null} +
+ ) : null} + {result.errors.length > 0 ? ( +
    + {result.errors.map((e) => ( +
  • + + {t("recordImport.line", { line: fmt.integer(e.line) })} + + + {t(`recordImport.reason.${e.reason}`)} + {e.field + ? ` (${t(`recordImport.field.${e.field}`)})` + : ""} + +
  • + ))} +
+ ) : null} + {result.errorsTruncated ? ( +

+ {t("recordImport.errorsTruncated")} +

+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 4846c99eb..b6d7f51ae 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -679,6 +679,19 @@ export function SourcesSection() { {t("settings.sections.sources.integrationsHintLink")}

+ {/* v1.44 (#1204) — the read-only view of what this ladder decides: + which source delivered what on a day, and two sources side by + side. Above the action row, like the hint before it. */} +

+ {t("sourceProvenance.settingsHint")}{" "} + + {t("sourceProvenance.settingsHintLink")} + +

{/* One action row, last in the card (design standards §12). The reset used to ride the header's status slot, which carries diff --git a/src/hooks/use-activity-calendar.ts b/src/hooks/use-activity-calendar.ts new file mode 100644 index 000000000..c69f843ea --- /dev/null +++ b/src/hooks/use-activity-calendar.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; +import type { + ActivityGoals, + ActivityGoalsInput, +} from "@/lib/validations/activity-goals"; + +/** + * `GET /api/activity/calendar` for one period. `date` null = the period that + * contains today. The server computes everything, the client only draws it. + */ +export function useActivityCalendar( + period: "week" | "month", + date: string | null, + opts?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: queryKeys.activityCalendar(period, date), + queryFn: () => { + const params = new URLSearchParams({ period }); + if (date) params.set("date", date); + return apiGet( + `/api/activity/calendar?${params.toString()}`, + ); + }, + enabled: opts?.enabled, + staleTime: 60_000, + }); +} + +/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */ +export function useSaveActivityGoals() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (patch: ActivityGoalsInput) => + apiPatch("/api/activity/goals", patch), + onSuccess: (goals) => { + queryClient.setQueryData(queryKeys.activityGoals(), goals); + void queryClient.invalidateQueries({ + queryKey: queryKeys.activityCalendarAll(), + }); + }, + }); +} diff --git a/src/hooks/use-source-provenance.ts b/src/hooks/use-source-provenance.ts new file mode 100644 index 000000000..27a609e02 --- /dev/null +++ b/src/hooks/use-source-provenance.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DayProvenanceResponse, + SourceComparisonResponse, +} from "@/lib/validations/source-provenance"; + +/** `GET /api/measurements/provenance` for one metric and UTC day. */ +export function useDayProvenance(type: string, date: string) { + return useQuery({ + queryKey: queryKeys.provenanceDay(type, date), + queryFn: () => + apiGet( + `/api/measurements/provenance?${new URLSearchParams({ type, date })}`, + ), + staleTime: 60_000, + }); +} + +/** `GET /api/measurements/provenance/compare` over a range of UTC days. */ +export function useSourceComparison(opts: { + type: string; + from: string; + to: string; + a?: string; + b?: string; +}) { + return useQuery({ + queryKey: queryKeys.provenanceCompare(opts), + queryFn: () => { + const params = new URLSearchParams({ + type: opts.type, + from: opts.from, + to: opts.to, + }); + if (opts.a) params.set("a", opts.a); + if (opts.b) params.set("b", opts.b); + return apiGet( + `/api/measurements/provenance/compare?${params}`, + ); + }, + staleTime: 60_000, + }); +} diff --git a/src/lib/activity/__tests__/calendar-math.test.ts b/src/lib/activity/__tests__/calendar-math.test.ts new file mode 100644 index 000000000..816cec6ab --- /dev/null +++ b/src/lib/activity/__tests__/calendar-math.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; +import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals"; + +import { + BASELINE_DAYS, + MIN_BASELINE_DAYS, + activeMinutesOf, + baselineStats, + buildActivityCalendar, + comparePeriod, + dayLevel, + daysInMonth, + goalProgress, + periodBounds, + quantileSorted, + shiftMonth, + weekStartOf, + type ActivityDayInput, +} from "../calendar-math"; + +function day( + date: string, + over: Partial = {}, +): ActivityDayInput { + return { + date, + steps: null, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + ...over, + }; +} + +describe("quantiles", () => { + it("interpolates linearly between ranks (PERCENTILE.INC)", () => { + const s = [1, 2, 3, 4, 5]; + expect(quantileSorted(s, 0)).toBe(1); + expect(quantileSorted(s, 0.5)).toBe(3); + expect(quantileSorted(s, 0.25)).toBe(2); + expect(quantileSorted([10, 20], 0.5)).toBe(15); + expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37); + }); + + it("refuses a baseline below the day floor", () => { + expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull(); + const stats = baselineStats( + Array.from({ length: 20 }, (_, i) => (i + 1) * 1000), + ); + expect(stats?.days).toBe(20); + expect(stats?.median).toBe(10500); + }); +}); + +describe("day level against the person's own spread", () => { + const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!; + it.each([ + [null, "none"], + [5, "low"], + [20, "below"], + [50, "usual"], + [80, "above"], + [95, "high"], + ] as const)("%s → %s", (value, level) => { + expect(dayLevel(value, stats)).toBe(level); + }); + + it("is unrated without a baseline, not guessed", () => { + expect(dayLevel(9000, null)).toBe("unrated"); + }); +}); + +describe("active minutes never double-count a workout", () => { + it("takes the larger of exercise and workout minutes", () => { + expect( + activeMinutesOf( + day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }), + true, + ), + ).toBe(40); + expect( + activeMinutesOf( + day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }), + true, + ), + ).toBe(45); + }); + it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => { + expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0); + expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull(); + }); +}); + +describe("period bounds", () => { + it("weeks run Monday to Sunday", () => { + // 2026-10-21 is a Wednesday. + expect(weekStartOf("2026-10-21")).toBe("2026-10-19"); + expect(weekStartOf("2026-10-19")).toBe("2026-10-19"); + expect(weekStartOf("2026-10-25")).toBe("2026-10-19"); + const b = periodBounds("week", "2026-10-21"); + expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]); + expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS)); + }); + + it("a month grid covers whole weeks and reads six months back", () => { + const b = periodBounds("month", "2026-02-14"); + expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]); + expect(b.gridStart).toBe("2026-01-26"); + expect(b.gridEnd).toBe("2026-03-01"); + expect(b.readStart).toBe("2025-08-01"); + }); + + it("month arithmetic handles length and year turns", () => { + expect(daysInMonth("2024-02-01")).toBe(29); + expect(daysInMonth("2026-02-01")).toBe(28); + expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01"); + expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01"); + }); +}); + +describe("period against the usual period", () => { + it("compares a finished week with the median of the twelve before", () => { + const values = new Map(); + // Twelve earlier weeks at 7 000 a day, this week at 10 000 a day. + const bounds = periodBounds("week", "2026-10-05"); + for (let i = 1; i <= 84; i++) + values.set(shiftDateKey(bounds.start, -i), 7000); + for (let i = 0; i < 7; i++) + values.set(shiftDateKey(bounds.start, i), 10000); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => values.get(d) ?? null, + mode: "sum", + }); + expect(result.value).toBe(70000); + expect(result.usual).toBe(49000); + expect(result.usualToDate).toBe(49000); + expect(result.referencePeriods).toBe(12); + }); + + it("pro-rates the usual for a period still running", () => { + const bounds = periodBounds("week", "2026-10-21"); + const values = new Map(); + for (let i = 1; i <= 84; i++) + values.set(shiftDateKey(bounds.start, -i), 1000); + const result = comparePeriod({ + bounds, + today: "2026-10-21", // Wednesday: three days elapsed + valueOf: (d) => + values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null), + mode: "sum", + }); + expect(result.value).toBe(6000); + expect(result.usual).toBe(7000); + expect(result.usualToDate).toBe(3000); + }); + + it("compares months per day, so February meets March fairly", () => { + const bounds = periodBounds("month", "2026-02-10"); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => (d < "2026-03-01" ? 100 : null), + mode: "sum", + }); + expect(result.value).toBe(2800); + // Six earlier months at 100 a day → 28 days × 100. + expect(result.usual).toBe(2800); + }); + + it("gives no usual with fewer than three earlier periods of data", () => { + const bounds = periodBounds("week", "2026-10-05"); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null), + mode: "sum", + }); + expect(result.referencePeriods).toBe(2); + expect(result.usual).toBeNull(); + }); +}); + +describe("goals", () => { + it("reports only goals that are set, for the period they belong to", () => { + const goals = { + ...NO_ACTIVITY_GOALS, + workoutsPerWeek: 3, + stepsPerMonth: 200000, + }; + expect(goalProgress("week", goals, { workouts: 4 })).toEqual([ + { key: "workoutsPerWeek", target: 3, value: 4, met: true }, + ]); + expect(goalProgress("month", goals, { steps: 150000 })).toEqual([ + { key: "stepsPerMonth", target: 200000, value: 150000, met: false }, + ]); + expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual( + [], + ); + }); +}); + +describe("the assembled calendar", () => { + const today = "2026-10-21"; + const bounds = periodBounds("month", today); + // 84 baseline days of 5 000–9 000 steps, a run every Monday. + const input: ActivityDayInput[] = []; + for (let i = 1; i <= 200; i++) { + const date = shiftDateKey(bounds.start, -i); + input.push( + day(date, { + steps: 5000 + (i % 5) * 1000, + workoutCount: i % 7 === 0 ? 1 : 0, + workoutMinutes: i % 7 === 0 ? 30 : 0, + workoutsBySport: i % 7 === 0 ? { running: 1 } : {}, + }), + ); + } + input.push( + day("2026-10-02", { + steps: 15000, + workoutCount: 2, + workoutMinutes: 90, + workoutsBySport: { running: 1, cycling: 1 }, + }), + ); + input.push(day("2026-10-03", { steps: 2000 })); + const calendar = buildActivityCalendar({ + bounds, + today, + input, + goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 }, + }); + + it("draws whole weeks and marks the days after today as future", () => { + expect(calendar.days[0].date).toBe(calendar.gridStart); + expect(calendar.days.length % 7).toBe(0); + const future = calendar.days.find((d) => d.date === "2026-10-22"); + expect(future?.future).toBe(true); + expect(future?.level.steps).toBe("none"); + }); + + it("rates a big day high and a short one low against the person's own spread", () => { + expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS); + expect( + calendar.days.find((d) => d.date === "2026-10-02")?.level.steps, + ).toBe("high"); + expect( + calendar.days.find((d) => d.date === "2026-10-03")?.level.steps, + ).toBe("low"); + expect( + calendar.days.find((d) => d.date === "2026-10-04")?.level.steps, + ).toBe("none"); + }); + + it("counts workouts by sport in the period", () => { + expect(calendar.sports).toEqual([ + { sportType: "cycling", count: 1 }, + { sportType: "running", count: 1 }, + ]); + expect(calendar.summary.workouts.value).toBe(2); + }); + + it("marks weekly goals per calendar row, and none for a week not yet begun", () => { + const week = calendar.weeks.find((w) => w.start === "2026-09-28"); + expect(week?.goals).toEqual([ + { key: "workoutsPerWeek", target: 2, value: 2, met: true }, + ]); + const last = calendar.weeks[calendar.weeks.length - 1]; + expect(last.start > today).toBe(true); + expect(last.goals).toEqual([]); + expect(calendar.next).toBeNull(); + expect(calendar.previous).toBe("2026-09-01"); + }); + + it("offers only the signals the account has", () => { + expect(calendar.available).toEqual({ + steps: true, + activeMinutes: true, + workouts: true, + load: false, + }); + }); +}); diff --git a/src/lib/activity/calendar-math.ts b/src/lib/activity/calendar-math.ts new file mode 100644 index 000000000..6f4a5e225 --- /dev/null +++ b/src/lib/activity/calendar-math.ts @@ -0,0 +1,609 @@ +/** + * Activity calendar arithmetic (D1036). Pure: no database, no clock, no zone. + * + * The calendar compares a week or a month of activity with the person's own + * normal, never with a population norm. Two comparisons, each with its own + * honest reference: + * + * - A DAY is placed against the spread of the person's own days in the 84 + * days before the period starts (`BASELINE_DAYS`): below the 10th + * percentile is `low`, below the 25th `below`, above the 75th `above`, + * above the 90th `high`, everything between `usual`. With fewer than + * `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`: + * there is not enough history to say what usual is, and the calendar says + * so instead of guessing. + * - A PERIOD total is placed against the median total of the same kind of + * period before it: the twelve weeks before a week, the six months before + * a month. A month is compared per day (months differ in length), so the + * usual month is the median daily rate of the earlier months times this + * month's length. A period still running is compared with the usual rate + * times the days elapsed so far, not with a full period it cannot have + * reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data + * are needed; fewer gives no usual. + * + * What a day's value is: + * - steps: the day's step total (the same canonical-source sum the steps + * chart draws); a day without a step reading has no value, it is not 0, + * because a watch left on the charger is not a day without steps. + * - active minutes: the larger of the day's exercise minutes and the day's + * workout minutes. Exercise minutes already contain the workouts a watch + * recorded, so adding the two would count a workout twice; taking the + * larger never does, and still counts a workout logged by hand on a day + * the watch recorded nothing. Once the account has any activity data in + * the window, a day without either is 0. + * - workouts: the number of workouts that started that day, after the + * cross-source duplicate collapse the workout list applies. Shown as + * counts, never rated: most days have none, so percentiles of a mostly-zero + * series say nothing. + * - load: the day's training-load score when a device or HealthLog computes + * one; no value otherwise. + */ +import { + daysBetweenDateKeys, + shiftDateKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; + +import type { + ActivityGoalKey, + ActivityGoals, +} from "@/lib/validations/activity-goals"; + +/** Days before the period whose spread defines a usual day. */ +export const BASELINE_DAYS = 84; +/** Days with a value needed before a day is rated against the baseline. */ +export const MIN_BASELINE_DAYS = 14; +/** Earlier periods compared against: twelve weeks, six months. */ +export const REFERENCE_WEEKS = 12; +export const REFERENCE_MONTHS = 6; +/** Earlier periods with data needed before a usual period total is given. */ +export const MIN_REFERENCE_PERIODS = 3; + +export type ActivityPeriod = "week" | "month"; +export type RatedMetric = "steps" | "activeMinutes" | "load"; +export type DayLevel = + "none" | "unrated" | "low" | "below" | "usual" | "above" | "high"; + +/** One local day of raw signals, as the reader assembles it. */ +export interface ActivityDayInput { + /** Local calendar day, `YYYY-MM-DD`. */ + date: string; + steps: number | null; + exerciseMinutes: number | null; + workoutMinutes: number; + workoutCount: number; + workoutsBySport: Record; + load: number | null; +} + +export interface BaselineStats { + median: number; + p10: number; + p25: number; + p75: number; + p90: number; + /** Days with a value the stats were computed from. */ + days: number; +} + +/** + * Linear-interpolation quantile (the R-7 / spreadsheet `PERCENTILE.INC` + * definition) over an ascending-sorted array. `q` in [0, 1]. + */ +export function quantileSorted(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return Number.NaN; + if (sorted.length === 1) return sorted[0]; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); +} + +export function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + return quantileSorted(sorted, 0.5); +} + +export function baselineStats(values: readonly number[]): BaselineStats | null { + if (values.length < MIN_BASELINE_DAYS) return null; + const sorted = [...values].sort((a, b) => a - b); + return { + median: quantileSorted(sorted, 0.5), + p10: quantileSorted(sorted, 0.1), + p25: quantileSorted(sorted, 0.25), + p75: quantileSorted(sorted, 0.75), + p90: quantileSorted(sorted, 0.9), + days: sorted.length, + }; +} + +/** Where one day's value sits in the person's own spread. */ +export function dayLevel( + value: number | null, + baseline: BaselineStats | null, +): DayLevel { + if (value === null) return "none"; + if (!baseline) return "unrated"; + if (value < baseline.p10) return "low"; + if (value < baseline.p25) return "below"; + if (value > baseline.p90) return "high"; + if (value > baseline.p75) return "above"; + return "usual"; +} + +/** The active-minutes rule from the header: the larger, never the sum. */ +export function activeMinutesOf( + day: ActivityDayInput, + hasActivityData: boolean, +): number | null { + if (day.exerciseMinutes !== null) { + return Math.max(day.exerciseMinutes, day.workoutMinutes); + } + if (day.workoutMinutes > 0) return day.workoutMinutes; + return hasActivityData ? 0 : null; +} + +export function metricValue( + day: ActivityDayInput, + metric: RatedMetric, + hasActivityData: boolean, +): number | null { + switch (metric) { + case "steps": + return day.steps; + case "activeMinutes": + return activeMinutesOf(day, hasActivityData); + case "load": + return day.load; + } +} + +/** Monday on or before `date` (the calendar's weeks run Monday to Sunday). */ +export function weekStartOf(date: string): string { + const weekday = weekdayOfDateKey(date); // 0 = Sunday + return shiftDateKey(date, -((weekday + 6) % 7)); +} + +export function monthStartOf(date: string): string { + return `${date.slice(0, 7)}-01`; +} + +export function daysInMonth(monthStart: string): number { + const [y, m] = monthStart.split("-").map(Number); + return new Date(Date.UTC(y, m, 0)).getUTCDate(); +} + +/** First day of the month `delta` months from `monthStart`. */ +export function shiftMonth(monthStart: string, delta: number): string { + const [y, m] = monthStart.split("-").map(Number); + const index = y * 12 + (m - 1) + delta; + const year = Math.floor(index / 12); + const month = (index % 12) + 1; + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`; +} + +export interface PeriodBounds { + period: ActivityPeriod; + /** First and last day of the period, inclusive. */ + start: string; + end: string; + /** The grid the calendar draws: whole weeks around the period. */ + gridStart: string; + gridEnd: string; + /** The earliest day any comparison reads. */ + readStart: string; +} + +export function periodBounds( + period: ActivityPeriod, + anchor: string, +): PeriodBounds { + if (period === "week") { + const start = weekStartOf(anchor); + const end = shiftDateKey(start, 6); + return { + period, + start, + end, + gridStart: start, + gridEnd: end, + readStart: shiftDateKey( + start, + -Math.max(BASELINE_DAYS, REFERENCE_WEEKS * 7), + ), + }; + } + const start = monthStartOf(anchor); + const end = shiftDateKey(start, daysInMonth(start) - 1); + const gridStart = weekStartOf(start); + const gridEnd = shiftDateKey(weekStartOf(end), 6); + // Date keys order lexically, so the earliest is the smallest string. + const readStart = [ + shiftMonth(start, -REFERENCE_MONTHS), + shiftDateKey(start, -BASELINE_DAYS), + gridStart, + ].sort()[0]; + return { period, start, end, gridStart, gridEnd, readStart }; +} + +/** Every day key from `from` to `to`, inclusive. */ +export function dayRange(from: string, to: string): string[] { + const n = daysBetweenDateKeys(from, to); + const out: string[] = []; + for (let i = 0; i <= n; i++) out.push(shiftDateKey(from, i)); + return out; +} + +export interface PeriodComparison { + /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */ + value: number | null; + /** Days of the period that carry a value. */ + daysWithData: number; + /** The usual full period, or null when there is not enough history. */ + usual: number | null; + /** The usual figure for the days elapsed so far (equals `usual` for a finished period). */ + usualToDate: number | null; + /** Earlier periods the usual was computed from. */ + referencePeriods: number; +} + +export type SummaryMetric = RatedMetric | "workouts"; + +function aggregate( + values: ReadonlyArray, + mode: "sum" | "mean", +): { value: number | null; n: number } { + const present = values.filter((v): v is number => v !== null); + if (present.length === 0) return { value: null, n: 0 }; + const total = present.reduce((a, b) => a + b, 0); + return { + value: mode === "sum" ? total : total / present.length, + n: present.length, + }; +} + +/** The earlier periods a period is compared with, as inclusive day ranges. */ +export function referencePeriods( + bounds: Pick, +): Array<{ start: string; end: string; length: number }> { + const out: Array<{ start: string; end: string; length: number }> = []; + if (bounds.period === "week") { + for (let i = 1; i <= REFERENCE_WEEKS; i++) { + const start = shiftDateKey(bounds.start, -7 * i); + out.push({ start, end: shiftDateKey(start, 6), length: 7 }); + } + return out; + } + for (let i = 1; i <= REFERENCE_MONTHS; i++) { + const start = shiftMonth(bounds.start, -i); + const length = daysInMonth(start); + out.push({ start, end: shiftDateKey(start, length - 1), length }); + } + return out; +} + +export function comparePeriod(input: { + bounds: Pick; + /** The last day that has happened (today in the person's zone). */ + today: string; + valueOf: (date: string) => number | null; + mode: "sum" | "mean"; +}): PeriodComparison { + const { bounds, today, valueOf, mode } = input; + const lastCounted = bounds.end < today ? bounds.end : today; + const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1; + const elapsed = + lastCounted < bounds.start + ? 0 + : daysBetweenDateKeys(bounds.start, lastCounted) + 1; + const current = + elapsed === 0 + ? { value: null, n: 0 } + : aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode); + + // Each earlier period becomes a per-day rate (sum) or a mean (mean), so a + // 28-day February and a 31-day March compare on the same footing. + const rates: number[] = []; + for (const ref of referencePeriods(bounds)) { + const agg = aggregate(dayRange(ref.start, ref.end).map(valueOf), mode); + if (agg.value === null) continue; + rates.push(mode === "sum" ? agg.value / ref.length : agg.value); + } + const usualRate = + rates.length >= MIN_REFERENCE_PERIODS ? median(rates) : null; + const usual = + usualRate === null ? null : mode === "sum" ? usualRate * length : usualRate; + const usualToDate = + usualRate === null + ? null + : mode === "sum" + ? usualRate * elapsed + : usualRate; + return { + value: current.value, + daysWithData: current.n, + usual, + usualToDate: elapsed === 0 ? null : usualToDate, + referencePeriods: rates.length, + }; +} + +export interface GoalProgress { + key: ActivityGoalKey; + target: number; + value: number; + met: boolean; +} + +/** Which goals apply to which period, and which figure each one reads. */ +const GOALS_BY_PERIOD: Record< + ActivityPeriod, + Array<{ key: ActivityGoalKey; metric: SummaryMetric }> +> = { + week: [ + { key: "workoutsPerWeek", metric: "workouts" }, + { key: "activeMinutesPerWeek", metric: "activeMinutes" }, + { key: "stepsPerWeek", metric: "steps" }, + ], + month: [{ key: "stepsPerMonth", metric: "steps" }], +}; + +export function goalProgress( + period: ActivityPeriod, + goals: ActivityGoals, + totals: Partial>, +): GoalProgress[] { + const out: GoalProgress[] = []; + for (const { key, metric } of GOALS_BY_PERIOD[period]) { + const target = goals[key]; + if (target === null) continue; + const value = totals[metric] ?? 0; + out.push({ key, target, value, met: value >= target }); + } + return out; +} + +export interface CalendarDay { + date: string; + inPeriod: boolean; + future: boolean; + steps: number | null; + activeMinutes: number | null; + workoutCount: number; + workoutsBySport: Record; + load: number | null; + level: Record; +} + +export interface CalendarWeekRow { + start: string; + end: string; + steps: number | null; + activeMinutes: number | null; + workouts: number; + /** The weekly goals, met or not, for a week that has started. */ + goals: GoalProgress[]; +} + +export interface ActivityCalendar { + period: ActivityPeriod; + start: string; + end: string; + gridStart: string; + gridEnd: string; + today: string; + days: CalendarDay[]; + weeks: CalendarWeekRow[]; + baseline: Record; + summary: Record; + sports: Array<{ sportType: string; count: number }>; + goals: ActivityGoals; + goalProgress: GoalProgress[]; + available: Record; + previous: string; + next: string | null; +} + +/** + * Assemble the calendar from per-day inputs covering at least + * `bounds.readStart`..`bounds.gridEnd`. Days missing from `input` count as + * days without any reading. + */ +export function buildActivityCalendar(args: { + bounds: PeriodBounds; + today: string; + input: readonly ActivityDayInput[]; + goals: ActivityGoals; +}): ActivityCalendar { + const { bounds, today, input, goals } = args; + const byDate = new Map(input.map((d) => [d.date, d])); + const empty = (date: string): ActivityDayInput => ({ + date, + steps: null, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); + const dayOf = (date: string) => byDate.get(date) ?? empty(date); + + // The first day with any activity signal: from there on, a day without + // workouts or exercise minutes is a day of 0 active minutes, not a gap. + const firstActivityDay = + input + .filter( + (d) => + d.exerciseMinutes !== null || + d.workoutCount > 0 || + d.workoutMinutes > 0, + ) + .map((d) => d.date) + .sort()[0] ?? null; + const hasActivityOn = (date: string) => + firstActivityDay !== null && date >= firstActivityDay && date <= today; + + const valueOf = (metric: SummaryMetric) => (date: string) => { + if (date > today) return null; + const day = dayOf(date); + if (metric === "workouts") + return hasActivityOn(date) ? day.workoutCount : null; + return metricValue(day, metric, hasActivityOn(date)); + }; + + const baselineFrom = shiftDateKey(bounds.start, -BASELINE_DAYS); + const baselineTo = shiftDateKey(bounds.start, -1); + const baselineDays = dayRange(baselineFrom, baselineTo); + const ratedMetrics: RatedMetric[] = ["steps", "activeMinutes", "load"]; + const baseline = Object.fromEntries( + ratedMetrics.map((metric) => [ + metric, + baselineStats( + baselineDays + .map(valueOf(metric)) + .filter((v): v is number => v !== null), + ), + ]), + ) as Record; + + const days: CalendarDay[] = dayRange(bounds.gridStart, bounds.gridEnd).map( + (date) => { + const day = dayOf(date); + const future = date > today; + const steps = future ? null : day.steps; + const activeMinutes = future + ? null + : activeMinutesOf(day, hasActivityOn(date)); + const load = future ? null : day.load; + return { + date, + inPeriod: date >= bounds.start && date <= bounds.end, + future, + steps, + activeMinutes, + workoutCount: future ? 0 : day.workoutCount, + workoutsBySport: future ? {} : day.workoutsBySport, + load, + level: { + steps: dayLevel(steps, baseline.steps), + activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes), + load: dayLevel(load, baseline.load), + }, + }; + }, + ); + + const summary: Record = { + steps: comparePeriod({ + bounds, + today, + valueOf: valueOf("steps"), + mode: "sum", + }), + activeMinutes: comparePeriod({ + bounds, + today, + valueOf: valueOf("activeMinutes"), + mode: "sum", + }), + workouts: comparePeriod({ + bounds, + today, + valueOf: valueOf("workouts"), + mode: "sum", + }), + load: comparePeriod({ + bounds, + today, + valueOf: valueOf("load"), + mode: "mean", + }), + }; + + const sportCounts = new Map(); + for (const date of dayRange(bounds.start, bounds.end)) { + if (date > today) break; + for (const [sport, count] of Object.entries(dayOf(date).workoutsBySport)) { + sportCounts.set(sport, (sportCounts.get(sport) ?? 0) + count); + } + } + const sports = [...sportCounts] + .map(([sportType, count]) => ({ sportType, count })) + .sort( + (a, b) => b.count - a.count || a.sportType.localeCompare(b.sportType), + ); + + const weeks: CalendarWeekRow[] = []; + for ( + let start = bounds.gridStart; + start <= bounds.gridEnd; + start = shiftDateKey(start, 7) + ) { + const end = shiftDateKey(start, 6); + const range = dayRange(start, end); + const sum = (metric: SummaryMetric) => + aggregate(range.map(valueOf(metric)), "sum").value; + const row = { + start, + end, + steps: sum("steps"), + activeMinutes: sum("activeMinutes"), + workouts: sum("workouts") ?? 0, + }; + weeks.push({ + ...row, + goals: + start > today + ? [] + : goalProgress("week", goals, { + steps: row.steps, + activeMinutes: row.activeMinutes, + workouts: row.workouts, + }), + }); + } + + const progress = goalProgress(bounds.period, goals, { + steps: summary.steps.value, + activeMinutes: summary.activeMinutes.value, + workouts: summary.workouts.value, + }); + + const anyIn = (pick: (d: ActivityDayInput) => boolean) => input.some(pick); + const available: Record = { + steps: anyIn((d) => d.steps !== null), + activeMinutes: firstActivityDay !== null, + workouts: anyIn((d) => d.workoutCount > 0), + load: anyIn((d) => d.load !== null), + }; + + const previous = + bounds.period === "week" + ? shiftDateKey(bounds.start, -7) + : shiftMonth(bounds.start, -1); + const nextStart = + bounds.period === "week" + ? shiftDateKey(bounds.start, 7) + : shiftMonth(bounds.start, 1); + + return { + period: bounds.period, + start: bounds.start, + end: bounds.end, + gridStart: bounds.gridStart, + gridEnd: bounds.gridEnd, + today, + days, + weeks, + baseline, + summary, + sports, + goals, + goalProgress: progress, + available, + previous, + next: nextStart > today ? null : nextStart, + }; +} diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts new file mode 100644 index 000000000..470fc0aa9 --- /dev/null +++ b/src/lib/activity/calendar-read.ts @@ -0,0 +1,181 @@ +/** + * Database side of the activity calendar (D1036): per local day, the step + * total, the exercise minutes, the training load and the workouts, over the + * window `periodBounds` asks for. The arithmetic lives in `calendar-math.ts`. + * + * Steps follow the same rule as the steps chart: each UTC day resolves to the + * source the person's priority ladder picks (`seriesRowsFrom`, the helper the + * series route reads through), and the picked rows are then cut at the + * person's own midnight. A calendar day and the chart's day therefore show + * the same number. Workouts go through the cross-source collapse the workout + * list applies, so a session recorded by two devices counts once. + * + * Bounded: four aggregate queries returning at most one row per day of the + * window (about 220 days for a month), plus the workouts of that window. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +import type { ActivityDayInput } from "./calendar-math"; + +/** + * Training-load types in the order the calendar prefers them: a device's own + * daily figure first (WHOOP day strain, Polar cardio load), HealthLog's + * computed strain score last. The first type with any reading in the window + * is the one shown; two scales are never mixed on one calendar. + */ +export const LOAD_TYPES: readonly MeasurementType[] = [ + "DAY_STRAIN", + "CARDIO_LOAD", + "STRAIN_SCORE", +]; + +const DAY_MS = 86_400_000; + +interface DayAggregateRow { + day: string; + total: number; + /** Readings that day; the load is their plain mean (one score a day, as a rule). */ + n: number; +} + +async function dailyAggregate( + userId: string, + tz: string, + priorityJson: unknown, + type: MeasurementType, + from: Date, + to: Date, + now: Date, +): Promise> { + // `seriesRowsFrom` scopes its canonical pick to a trailing window counted + // from now; it is widened by a day so the edge day's source is picked from + // all of its readings, and the outer filter trims to the exact window. + const days = Math.min( + 3649, + Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS) + 1), + ); + const rows = await prisma.$queryRawUnsafe( + ` + SELECT + to_char( + date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $2), + 'YYYY-MM-DD' + ) AS day, + SUM(m."value")::double precision AS total, + COUNT(*)::int AS n + FROM ${seriesRowsFrom(priorityJson, type, days)} + WHERE m."measured_at" >= $3 + AND m."measured_at" < $4 + GROUP BY 1 + `, + userId, + tz, + from, + to, + ); + return new Map(rows.map((r) => [r.day, r])); +} + +function meanOf(row: DayAggregateRow | undefined): number | null { + return row && row.n > 0 ? row.total / row.n : null; +} + +export interface ActivityReadResult { + days: ActivityDayInput[]; + loadType: MeasurementType | null; +} + +/** + * Read `fromKey`..`toKey` (inclusive local days) for one account. + * `today` caps the read: nothing after the person's today is fetched. + */ +export async function readActivityDays(args: { + userId: string; + tz: string; + fromKey: string; + toKey: string; + now?: Date; + includeWorkouts: boolean; +}): Promise { + const { userId, tz, fromKey, toKey, includeWorkouts } = args; + const now = args.now ?? new Date(); + const from = startOfLocalDayKey(fromKey, tz); + const to = startOfLocalDayKey(shiftDateKey(toKey, 1), tz); + const priorityJson = await loadUserSourcePriority(userId); + + const [steps, exercise, loads, workoutRows] = await Promise.all([ + dailyAggregate(userId, tz, priorityJson, "ACTIVITY_STEPS", from, to, now), + dailyAggregate(userId, tz, priorityJson, "EXERCISE_MINUTES", from, to, now), + Promise.all( + LOAD_TYPES.map((type) => + dailyAggregate(userId, tz, priorityJson, type, from, to, now), + ), + ), + includeWorkouts + ? prisma.workout.findMany({ + where: { userId, startedAt: { gte: from, lt: to } }, + select: { + startedAt: true, + durationSec: true, + sportType: true, + source: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + orderBy: { startedAt: "asc" }, + }) + : Promise.resolve([]), + ]); + + const loadIndex = loads.findIndex((m) => m.size > 0); + const load = loadIndex >= 0 ? loads[loadIndex] : null; + + const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson); + const workoutsByDay = new Map< + string, + { minutes: number; count: number; bySport: Record } + >(); + for (const w of workouts) { + const key = userDayKey(w.startedAt, tz); + const slot = workoutsByDay.get(key) ?? { + minutes: 0, + count: 0, + bySport: {}, + }; + slot.minutes += Math.max(0, w.durationSec) / 60; + slot.count += 1; + slot.bySport[w.sportType] = (slot.bySport[w.sportType] ?? 0) + 1; + workoutsByDay.set(key, slot); + } + + const keys = new Set([ + ...steps.keys(), + ...exercise.keys(), + ...(load ? load.keys() : []), + ...workoutsByDay.keys(), + ]); + const days: ActivityDayInput[] = [...keys] + .filter((k) => k >= fromKey && k <= toKey) + .sort() + .map((date) => { + const w = workoutsByDay.get(date); + return { + date, + steps: steps.get(date)?.total ?? null, + exerciseMinutes: exercise.get(date)?.total ?? null, + workoutMinutes: w ? Math.round(w.minutes) : 0, + workoutCount: w?.count ?? 0, + workoutsBySport: w?.bySport ?? {}, + load: meanOf(load?.get(date)), + }; + }); + + return { days, loadType: loadIndex >= 0 ? LOAD_TYPES[loadIndex] : null }; +} diff --git a/src/lib/activity/goal-bounds.ts b/src/lib/activity/goal-bounds.ts new file mode 100644 index 000000000..40b59721a --- /dev/null +++ b/src/lib/activity/goal-bounds.ts @@ -0,0 +1,27 @@ +/** + * The activity-goal keys and their bounds, without a schema library, so the + * goals sheet can read them in the browser. The schema that enforces them + * on the server is `src/lib/validations/activity-goals.ts`. + */ +export const ACTIVITY_GOAL_KEYS = [ + "workoutsPerWeek", + "activeMinutesPerWeek", + "stepsPerWeek", + "stepsPerMonth", +] as const; + +export type ActivityGoalKey = (typeof ACTIVITY_GOAL_KEYS)[number]; + +/** + * Wide on purpose: they refuse typing mistakes (a goal of 5 000 000 steps a + * week), they do not judge what a sensible goal is. + */ +export const ACTIVITY_GOAL_BOUNDS: Record< + ActivityGoalKey, + { min: number; max: number } +> = { + workoutsPerWeek: { min: 1, max: 28 }, + activeMinutesPerWeek: { min: 10, max: 5000 }, + stepsPerWeek: { min: 1000, max: 700_000 }, + stepsPerMonth: { min: 1000, max: 3_000_000 }, +}; diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 5395b35b5..316c278d5 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -489,6 +489,7 @@ export const USER_RESET = { moodTagLayoutJson: Prisma.DbNull, documentsLayoutJson: Prisma.DbNull, moduleListLayoutJson: Prisma.DbNull, + activityGoalsJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, healthScoreConfigJson: Prisma.DbNull, diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index d2f2bced6..6a0ad5f02 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -61,6 +61,7 @@ import { import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { locales } from "@/lib/i18n/config"; import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { activityGoalsSchema } from "@/lib/validations/activity-goals"; import { isPublicUrl } from "@/lib/validations/notifications"; import { glucoseUnitPatchSchema, @@ -267,6 +268,37 @@ const thresholds: SettingCodec = { }, }; +/** + * The activity goals, goal by goal. A goal outside its bounds, or a key this + * release does not know, is dropped and named; the valid goals beside it + * still come back. + */ +const activityGoals: SettingCodec = { + write: (row) => ({ activityGoalsJson: row.activityGoalsJson ?? null }), + read: (entry) => { + if (!has(entry, "activityGoalsJson")) return undefined; + const value = entry.activityGoalsJson; + if (value === null) return Prisma.DbNull; + if (typeof value !== "object" || Array.isArray(value)) { + return new Refused("activityGoalsJson"); + } + const kept: Record = {}; + const refused: string[] = []; + for (const [key, goal] of Object.entries(value)) { + const parsed = activityGoalsSchema.safeParse({ [key]: goal }); + if (!parsed.success) { + refused.push(`activityGoalsJson.${key}`); + } else if (typeof goal === "number") { + kept[key] = goal; + } + } + return new PartlyRefused( + Object.keys(kept).length === 0 ? Prisma.DbNull : kept, + refused, + ); + }, +}; + const injectionSites = Object.values(InjectionSite) as string[]; /** The injection sites to avoid everywhere; an unknown site is dropped. */ @@ -505,6 +537,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< documentsLayoutJson: json("documentsLayoutJson"), moduleListLayoutJson: json("moduleListLayoutJson"), previewFeaturesJson: json("previewFeaturesJson"), + activityGoalsJson: activityGoals, reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, healthKitConfigJson: json("healthKitConfigJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index c4a1137fd..86dca6347 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1019,6 +1019,7 @@ export const USER_COLUMN_BACKUP_CLASS = { documentsLayoutJson: "SETTING", moduleListLayoutJson: "SETTING", previewFeaturesJson: "SETTING", + activityGoalsJson: "SETTING", reportSelectionJson: "SETTING", globalExcludedInjectionSites: "SETTING", healthKitConfigJson: "SETTING", diff --git a/src/lib/http/proxy-bypass-routes.ts b/src/lib/http/proxy-bypass-routes.ts index 39434d3b5..339dc418f 100644 --- a/src/lib/http/proxy-bypass-routes.ts +++ b/src/lib/http/proxy-bypass-routes.ts @@ -42,6 +42,9 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [ // Bulk JSON / CSV imports, 16 MB. "/api/import", "/api/import/csv", + // Lab and medication CSV imports (v1.44), streamed, 16 MB. + "/api/import/labs-csv", + "/api/import/medications-csv", "/api/medications/intake/dose-history-import", // Lab scan upload, 12 MB. "/api/labs/ocr/extract", diff --git a/src/lib/import/__tests__/csv-mapping.test.ts b/src/lib/import/__tests__/csv-mapping.test.ts new file mode 100644 index 000000000..11c249c75 --- /dev/null +++ b/src/lib/import/__tests__/csv-mapping.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, +} from "@/lib/validations/record-import"; + +import { + RECORD_IMPORT_TEMPLATES, + guessFormat, + guessMapping, + readHead, +} from "../csv-mapping"; +import { normaliseLabRow } from "../labs-csv"; +import { normaliseMedicationRow } from "../medications-csv"; + +describe("guessMapping", () => { + it("reads English and German headers, required fields first", () => { + expect( + guessMapping( + ["Analyte", "Result", "Unit", "Date", "Reference range"], + "labs", + ), + ).toEqual({ + analyte: 0, + value: 1, + unit: 2, + date: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Datum", "Laborwert", "Wert", "Einheit", "Referenzbereich"], + "labs", + ), + ).toMatchObject({ + date: 0, + analyte: 1, + value: 2, + unit: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Medikament", "Dosis", "Uhrzeiten", "Bei Bedarf", "Beginn", "Ende"], + "medications", + ), + ).toEqual({ + name: 0, + dose: 1, + times: 2, + asNeeded: 3, + startsOn: 4, + endsOn: 5, + }); + }); + + it("offers a column to one field only and leaves unknown headers alone", () => { + const m = guessMapping(["name", "foo", "bar"], "labs"); + expect(m).toEqual({ analyte: 0 }); + }); +}); + +describe("guessFormat", () => { + it("sees a German file", () => { + const head = readHead("Analyt;Wert;Datum\nHbA1c;5,4;01.03.2025\n"); + expect(head.delimiter).toBe(";"); + expect(guessFormat(head, 2)).toEqual({ + decimal: "comma", + dateOrder: "dmy", + }); + }); + it("sees a US file by a day above twelve", () => { + const head = readHead("a,b,date\nx,5.4,03/15/2025\n"); + expect(guessFormat(head, 2)).toEqual({ decimal: "dot", dateOrder: "mdy" }); + }); + it("sees ISO", () => { + expect(guessFormat(readHead("a,date\nx,2025-03-01\n"), 1).dateOrder).toBe( + "iso", + ); + }); +}); + +describe("the templates", () => { + it("import cleanly with the mapping the screen suggests", () => { + const labs = readHead(RECORD_IMPORT_TEMPLATES.labs, 10); + const labMap = guessMapping(labs.rows[0], "labs"); + const labQuery = labsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(labMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of labs.rows.slice(1).entries()) { + expect(normaliseLabRow({ line: i + 2, fields }, labQuery, "UTC").ok).toBe( + true, + ); + } + const meds = readHead(RECORD_IMPORT_TEMPLATES.medications, 10); + const medMap = guessMapping(meds.rows[0], "medications"); + const medQuery = medicationsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(medMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of meds.rows.slice(1).entries()) { + expect( + normaliseMedicationRow({ line: i + 2, fields }, medQuery, "UTC").ok, + ).toBe(true); + } + }); +}); diff --git a/src/lib/import/__tests__/csv-stream.test.ts b/src/lib/import/__tests__/csv-stream.test.ts new file mode 100644 index 000000000..cbd4106c8 --- /dev/null +++ b/src/lib/import/__tests__/csv-stream.test.ts @@ -0,0 +1,227 @@ +/** + * The streaming CSV reader: the spreadsheet shapes, every chunk boundary, a + * fuzzed round trip, and a large file read with bounded memory. + */ +import { describe, expect, it } from "vitest"; + +import { + CsvTokenizer, + CsvTooLargeError, + MAX_COLUMNS, + MAX_FIELD_CHARS, + detectDelimiter, + readCsvRecords, + textToStream, + type CsvRecord, +} from "../csv-stream"; + +function all(text: string, delimiter: "auto" | "," | ";" | "\t" = "auto") { + const t = new CsvTokenizer(delimiter); + return [...t.push(text), ...t.end()]; +} + +/** Feed the text split at every position, one boundary at a time. */ +function everySplit(text: string): CsvRecord[][] { + const out: CsvRecord[][] = []; + for (let i = 0; i <= text.length; i++) { + const t = new CsvTokenizer(); + out.push([ + ...t.push(text.slice(0, i)), + ...t.push(text.slice(i)), + ...t.end(), + ]); + } + return out; +} + +const fields = (records: CsvRecord[]) => records.map((r) => r.fields); + +describe("detectDelimiter", () => { + it.each([ + ["a,b,c", ","], + ["a;b;c", ";"], + ["a\tb\tc", "\t"], + ['"x;y",b,c', ","], + ["single", ","], + ] as const)("%j → %j", (line, expected) => { + expect(detectDelimiter(line)).toBe(expected); + }); +}); + +describe("CsvTokenizer", () => { + it("drops a byte-order mark and reads a semicolon file with decimal commas", () => { + expect(fields(all("Analyt;Wert;Einheit\nHbA1c;5,4;%\n"))).toEqual([ + ["Analyt", "Wert", "Einheit"], + ["HbA1c", "5,4", "%"], + ]); + }); + + it("keeps separators, quotes and line breaks inside quoted fields", () => { + const text = 'a,b\n"x, y","say ""hi""\nthere"\n'; + expect(fields(all(text))).toEqual([ + ["a", "b"], + ["x, y", 'say "hi"\nthere'], + ]); + }); + + it("reads LF, CRLF and lone CR line ends and skips blank lines", () => { + expect(fields(all("a,b\r\n1,2\r3,4\n\n\r\n5,6"))).toEqual([ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); + }); + + it("reports the physical line each record starts on", () => { + const records = all('h\n"multi\nline"\n\nlast\n'); + expect(records.map((r) => r.line)).toEqual([1, 2, 5]); + }); + + it("keeps empty fields and a trailing empty field", () => { + expect(fields(all("a,,c,\n"))).toEqual([["a", "", "c", ""]]); + }); + + it("gives the same records whatever the chunk boundaries", () => { + const text = + 'name;dose;"note"\r\n"Ram;ipril";"5 mg";"two\r\nlines ""q"""\r\nX;1;\r'; + const expected = fields(all(text)); + for (const records of everySplit(text)) { + expect(fields(records)).toEqual(expected); + } + }); + + it("marks a runaway field instead of growing without end", () => { + const records = all(`a\n"${"x".repeat(MAX_FIELD_CHARS + 50)}\n`); + expect(records[1].oversized).toBe(true); + expect(records[1].fields[0].length).toBe(MAX_FIELD_CHARS); + }); + + it("marks a record wider than the column cap", () => { + const wide = Array.from({ length: MAX_COLUMNS + 5 }, (_, i) => + String(i), + ).join(","); + const [record] = all(`${wide}\n`); + expect(record.oversized).toBe(true); + expect(record.fields).toHaveLength(MAX_COLUMNS); + }); +}); + +/** Deterministic pseudo-random so a failure reproduces. */ +function rng(seed: number) { + let s = seed; + return () => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s / 0x7fffffff; + }; +} + +function quoteField(value: string) { + return /[",;\t\r\n]/.test(value) || value === "" + ? `"${value.replace(/"/g, '""')}"` + : value; +} + +describe("fuzzed round trip", () => { + it("reads back exactly what was written, for random tables and chunkings", () => { + const alphabet = [ + "a", + "Z", + "1", + ",", + ";", + '"', + "\n", + "\r\n", + " ", + "ä", + "漢", + "\t", + ".", + ]; + const rand = rng(1215); + for (let round = 0; round < 300; round++) { + const delimiter = ([",", ";", "\t"] as const)[Math.floor(rand() * 3)]; + const cols = 1 + Math.floor(rand() * 5); + const rows = 1 + Math.floor(rand() * 6); + const table: string[][] = []; + for (let r = 0; r < rows; r++) { + const row: string[] = []; + for (let c = 0; c < cols; c++) { + let v = ""; + const len = Math.floor(rand() * 6); + for (let k = 0; k < len; k++) + v += alphabet[Math.floor(rand() * alphabet.length)]; + // A field that is all whitespace-free and quotable stays as written. + row.push(r === 0 ? `h${c}` : v); + } + // A record of only empty fields is skipped by design; keep one cell. + if (!row.some((f) => f.length > 0)) row[0] = "x"; + table.push(row); + } + const text = + table + .map((row) => row.map((f) => quoteField(f)).join(delimiter)) + .join(rand() < 0.5 ? "\n" : "\r\n") + (rand() < 0.5 ? "\n" : ""); + const t = new CsvTokenizer(delimiter); + const out: CsvRecord[] = []; + let i = 0; + while (i < text.length) { + const step = 1 + Math.floor(rand() * 7); + out.push(...t.push(text.slice(i, i + step))); + i += step; + } + out.push(...t.end()); + // Quoted line breaks come back as "\n": the reader normalises them. + const expected = table.map((row) => + row.map((f) => f.replace(/\r\n/g, "\n")), + ); + expect(fields(out), `round ${round}: ${JSON.stringify(text)}`).toEqual( + expected, + ); + } + }); +}); + +describe("readCsvRecords", () => { + it("streams a large file record by record", async () => { + const rows = 200_000; + const lines = ["analyte;value;unit;date"]; + for (let i = 0; i < rows; i++) + lines.push(`Marker ${i % 50};${i % 9},${i % 10};mg/dL;01.02.2024`); + const text = lines.join("\n"); + let count = 0; + let maxFieldLength = 0; + for await (const record of readCsvRecords(textToStream(text, 8192), { + maxBytes: 64 * 1024 * 1024, + })) { + count += 1; + for (const f of record.fields) + maxFieldLength = Math.max(maxFieldLength, f.length); + } + expect(count).toBe(rows + 1); + expect(maxFieldLength).toBeLessThan(20); + }); + + it("stops past the byte cap", async () => { + const text = "a,b\n" + "1,2\n".repeat(1000); + const consume = async () => { + for await (const _ of readCsvRecords(textToStream(text, 100), { + maxBytes: 500, + })) { + void _; + } + }; + await expect(consume()).rejects.toBeInstanceOf(CsvTooLargeError); + }); + + it("decodes a multi-byte character split across chunks", async () => { + const text = "name\nÄrztin 漢字\n"; + const out: string[] = []; + for await (const r of readCsvRecords(textToStream(text, 1), { + maxBytes: 1000, + })) + out.push(r.fields[0]); + expect(out).toEqual(["name", "Ärztin 漢字"]); + }); +}); diff --git a/src/lib/import/__tests__/csv-values.test.ts b/src/lib/import/__tests__/csv-values.test.ts new file mode 100644 index 000000000..d3c2ad27c --- /dev/null +++ b/src/lib/import/__tests__/csv-values.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { parseCsvDate, parseLocaleNumber, parseYesNo } from "../csv-values"; + +describe("parseLocaleNumber", () => { + it.each([ + ["5.4", "dot", 5.4], + ["5,4", "comma", 5.4], + ["-0.5", "dot", -0.5], + ["1,234.5", "dot", 1234.5], + ["1.234,5", "comma", 1234.5], + ["1,234,567", "dot", 1234567], + ["1.234.567", "comma", 1234567], + ["5,4", "dot", 5.4], + ["5.4", "comma", 5.4], + ["12", "comma", 12], + [" 7,25 ", "comma", 7.25], + ["1 234,5", "comma", 1234.5], + [".5", "dot", 0.5], + ] as const)("%j (%s) → %s", (raw, mark, value) => { + expect(parseLocaleNumber(raw, mark)).toEqual({ ok: true, value }); + }); + + it.each([ + ["1,234", "dot"], + ["1.234", "comma"], + ["1234,567", "dot"], + ] as const)("refuses %j (%s) as ambiguous", (raw, mark) => { + expect(parseLocaleNumber(raw, mark)).toEqual({ + ok: false, + reason: "ambiguous_number", + }); + }); + + it.each(["", "abc", "<0.5", "5..4", "1,2,3", "5.4.3", "1e5", "5,"])( + "refuses %j as not a number", + (raw) => { + expect(parseLocaleNumber(raw, "dot").ok).toBe(false); + }, + ); +}); + +describe("parseCsvDate", () => { + const berlin = { timezone: "Europe/Berlin" }; + + it("stores a date without a time on the date-only anchor", () => { + const r = parseCsvDate("2024-02-01", undefined, { + order: "iso", + ...berlin, + }); + expect(r).toMatchObject({ + ok: true, + dateKey: "2024-02-01", + dateOnly: true, + }); + expect(r.ok && r.instant.toISOString()).toBe("2024-02-01T12:00:00.000Z"); + }); + + it("reads a wall-clock time in the import's zone, across DST", () => { + const winter = parseCsvDate("15.01.2024", "08:30", { + order: "dmy", + ...berlin, + }); + const summer = parseCsvDate("15.07.2024 08:30", undefined, { + order: "dmy", + ...berlin, + }); + expect(winter.ok && winter.instant.toISOString()).toBe( + "2024-01-15T07:30:00.000Z", + ); + expect(summer.ok && summer.instant.toISOString()).toBe( + "2024-07-15T06:30:00.000Z", + ); + }); + + it("honours an explicit offset over the zone", () => { + const r = parseCsvDate("2024-07-15T08:30:00+02:00", undefined, { + order: "iso", + timezone: "America/New_York", + }); + expect(r.ok && r.instant.toISOString()).toBe("2024-07-15T06:30:00.000Z"); + }); + + it("reads US order and refuses a cell that cannot be in it", () => { + expect( + parseCsvDate("02/15/2024", undefined, { order: "mdy", ...berlin }), + ).toMatchObject({ ok: true, dateKey: "2024-02-15" }); + expect( + parseCsvDate("15/02/2024", undefined, { order: "mdy", ...berlin }), + ).toEqual({ ok: false, reason: "ambiguous_date" }); + }); + + it.each([ + ["31.02.2024", "dmy", "invalid_date"], + ["01.02.24", "dmy", "ambiguous_date"], + ["24-02-01", "iso", "ambiguous_date"], + ["Feb 1 2024", "iso", "invalid_date"], + ["", "iso", "invalid_date"], + ] as const)("%j (%s) → %s", (raw, order, reason) => { + expect(parseCsvDate(raw, undefined, { order, ...berlin })).toEqual({ + ok: false, + reason, + }); + }); + + it("refuses an impossible time", () => { + expect( + parseCsvDate("2024-02-01", "25:00", { order: "iso", ...berlin }), + ).toEqual({ ok: false, reason: "invalid_time" }); + }); +}); + +describe("parseYesNo", () => { + it("reads the usual spellings and nothing else", () => { + expect(parseYesNo("Ja")).toBe(true); + expect(parseYesNo("no")).toBe(false); + expect(parseYesNo("")).toBeNull(); + expect(parseYesNo("maybe")).toBeNull(); + }); +}); diff --git a/src/lib/import/__tests__/record-import-rows.test.ts b/src/lib/import/__tests__/record-import-rows.test.ts new file mode 100644 index 000000000..36507af4a --- /dev/null +++ b/src/lib/import/__tests__/record-import-rows.test.ts @@ -0,0 +1,269 @@ +/** + * Row normalisation of the lab and medication CSV imports (#1215): what a + * cell may mean, and the reason a row is refused when it could mean two + * things. Pure; the database half is in + * `tests/integration/record-csv-import.test.ts`. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createMedicationSchema } from "@/lib/validations/medication"; +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, +} from "@/lib/validations/record-import"; + +import { normaliseLabRow, readingKeys } from "../labs-csv"; +import { + medicationCreateBody, + normaliseMedicationRow, + parseTimes, +} from "../medications-csv"; + +const NOW = new Date("2026-10-21T10:00:00.000Z"); +const labQuery = (extra: Record = {}) => + labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + referenceRange: "4", + ...extra, + }); +const lab = (fields: string[], extra?: Record) => + normaliseLabRow({ line: 7, fields }, labQuery(extra), "Europe/Berlin", NOW); + +describe("lab rows", () => { + it("reads a numeric result with its unit, date and printed range", () => { + const r = lab(["HbA1c", "5.4", "%", "2025-03-01", "4.0-5.6"]); + expect(r).toMatchObject({ + ok: true, + row: { + line: 7, + analyte: "HbA1c", + value: 5.4, + valueText: null, + unit: "%", + sourceReferenceLow: 4, + sourceReferenceHigh: 5.6, + }, + }); + expect(r.ok && r.row.takenAt.toISOString()).toBe( + "2025-03-01T12:00:00.000Z", + ); + }); + + it("reads a German file: decimal comma, day-first dates, comma ranges", () => { + const r = lab(["Kreatinin", "0,92", "mg/dl", "01.03.2025", "0,7 - 1,2"], { + decimal: "comma", + dateOrder: "dmy", + }); + expect(r).toMatchObject({ + ok: true, + row: { value: 0.92, sourceReferenceLow: 0.7, sourceReferenceHigh: 1.2 }, + }); + }); + + it("keeps a non-numeric result as a qualitative reading", () => { + expect( + lab(["Urine protein", "negative", "", "2025-03-01", ""]), + ).toMatchObject({ + ok: true, + row: { value: null, valueText: "negative", unit: null }, + }); + expect(lab(["PSA", "<0.1", "ng/mL", "2025-03-01", ""])).toMatchObject({ + ok: true, + row: { valueText: "<0.1" }, + }); + }); + + it.each([ + [["", "5", "%", "2025-03-01", ""], "missing_value", "analyte"], + [["HbA1c", "", "%", "2025-03-01", ""], "missing_value", "value"], + [["HbA1c", "5.4", "", "2025-03-01", ""], "missing_unit", "unit"], + [ + ["Glucose", "1,234", "mg/dL", "2025-03-01", ""], + "ambiguous_number", + "value", + ], + [ + ["Glucose", "1.2.3", "mg/dL", "2025-03-01", ""], + "invalid_number", + "value", + ], + [["Glucose", "90", "mg/dL", "03/01/25", ""], "invalid_date", "date"], + [["Glucose", "90", "mg/dL", "2027-01-01", ""], "future_date", "date"], + ] as const)("refuses %j with %s on %s", (fields, reason, field) => { + expect(lab([...fields])).toEqual({ ok: false, reason, field }); + }); + + it("refuses a range whose low lies above its high", () => { + expect( + lab(["Glucose", "90", "mg/dL", "2025-03-01", "", "120", "70"], { + referenceRange: "4", + referenceLow: "5", + referenceHigh: "6", + }), + ).toEqual({ + ok: false, + reason: "invalid_reference_range", + field: "referenceRange", + }); + }); + + it("refuses a mapping that feeds two fields from one column", () => { + expect( + labsCsvQuerySchema.safeParse({ analyte: "0", value: "0", date: "1" }) + .success, + ).toBe(false); + }); + + it("keys a reading the way the scan commit's duplicate check does", () => { + const keys = readingKeys( + { + biomarkerId: "bm1", + analyte: "HbA1c", + takenAt: new Date("2025-03-01T12:00:00.000Z"), + value: 39, + valueText: null, + sourceValue: 5.7, + sourceUnit: "%", + }, + "Europe/Berlin", + ); + expect(keys).toEqual([ + "bm:bm1|2025-03-01|n:39", + "bm:bm1|2025-03-01|s:5.7:%", + ]); + }); +}); + +const medQuery = (extra: Record = {}) => + medicationsCsvQuerySchema.parse({ + name: "0", + dose: "1", + times: "2", + asNeeded: "3", + startsOn: "4", + endsOn: "5", + ...extra, + }); +const med = (fields: string[], extra?: Record) => + normaliseMedicationRow({ line: 3, fields }, medQuery(extra), "Europe/Berlin"); + +describe("medication rows", () => { + it.each([ + ["08:00", ["08:00"]], + ["20:00 8:00", ["08:00", "20:00"]], + ["8.00; 20.00|12:30", ["08:00", "12:30", "20:00"]], + ["", []], + ])("times %j → %j", (raw, expected) => { + expect(parseTimes(raw)).toEqual(expected); + }); + + it.each(["8", "25:00", "morning", "08:00 x"])("refuses times %j", (raw) => { + expect(parseTimes(raw)).toBeNull(); + }); + + it("reads a daily medication with a course", () => { + expect( + med(["Ramipril", "5 mg", "08:00", "", "01.02.2025", ""], { + dateOrder: "dmy", + }), + ).toEqual({ + ok: true, + row: { + line: 3, + name: "Ramipril", + dose: "5 mg", + times: ["08:00"], + asNeeded: false, + startsOn: "2025-02-01", + endsOn: null, + }, + }); + }); + + it.each([ + [["", "5 mg", "", "", "", ""], "missing_value", "name"], + [ + ["Ibuprofen", "400 mg", "08:00", "yes", "", ""], + "as_needed_with_times", + "times", + ], + [ + ["Ibuprofen", "400 mg", "", "sometimes", "", ""], + "invalid_yes_no", + "asNeeded", + ], + [ + ["X", "1", "08:00", "", "2025-03-01", "2025-02-01"], + "end_before_start", + "endsOn", + ], + [["Vitamin D", "1000 IE", "", "", "", ""], "missing_times", "times"], + ] as const)("refuses %j with %s", (fields, reason, field) => { + expect(med([...fields])).toEqual({ ok: false, reason, field }); + }); + + it("builds a body the create route's own schema accepts, reminders off by default", () => { + const daily = med(["Ramipril", "5 mg", "08:00 20:00", "", "", ""]); + const asNeeded = med(["Ibuprofen", "400 mg", "", "ja", "", ""]); + for (const r of [daily, asNeeded]) { + expect(r.ok).toBe(true); + if (!r.ok) continue; + const check = createMedicationSchema.safeParse( + medicationCreateBody(r.row, false), + ); + expect(check.success ? [] : check.error.issues).toEqual([]); + } + if (daily.ok) { + expect(medicationCreateBody(daily.row, false)).toMatchObject({ + notificationsEnabled: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "20:00", + timesOfDay: ["08:00", "20:00"], + }, + ], + }); + expect(medicationCreateBody(daily.row, true).notificationsEnabled).toBe( + true, + ); + } + if (asNeeded.ok) { + expect( + medicationCreateBody(asNeeded.row, true).notificationsEnabled, + ).toBe(false); + } + }); +}); + +describe("structure", () => { + const read = (p: string) => readFileSync(join(process.cwd(), p), "utf8"); + + it("writes field by field and never spreads a parsed row", () => { + for (const file of [ + "src/lib/import/labs-csv.ts", + "src/lib/import/medications-csv.ts", + ]) { + const src = read(file); + expect(src).not.toMatch(/data:\s*\{\s*\.\.\.(row|parsed|data)\b/); + expect(src).not.toMatch(/\.\.\.parsed\.data/); + } + }); + + it("checks every medication row with the create route's schema", () => { + expect(read("src/lib/import/medications-csv.ts")).toContain( + "createMedicationSchema.safeParse(", + ); + }); + + it("streams the body instead of reading it whole", () => { + const src = read("src/lib/import/record-import-route.ts"); + expect(src).toContain("stream: request.body"); + expect(src).not.toMatch(/request\.(text|json|arrayBuffer)\(/); + }); +}); diff --git a/src/lib/import/csv-mapping.ts b/src/lib/import/csv-mapping.ts new file mode 100644 index 000000000..e8135729a --- /dev/null +++ b/src/lib/import/csv-mapping.ts @@ -0,0 +1,345 @@ +/** + * The client half of the CSV record imports (#1215): reading the head of a + * file for the mapping screen, suggesting which column feeds which field and + * how the file writes numbers and dates, and the downloadable templates. + * + * Suggestions only. The person sees every choice on the mapping screen and + * the server checks every row against what was chosen; nothing here decides + * a value. + */ +import { CsvTokenizer, type CsvDelimiter } from "@/lib/import/csv-stream"; + +export type RecordImportKind = "labs" | "medications"; + +export const LAB_FIELDS = [ + { key: "analyte", required: true }, + { key: "value", required: true }, + { key: "unit", required: false }, + { key: "date", required: true }, + { key: "time", required: false }, + { key: "referenceRange", required: false }, + { key: "referenceLow", required: false }, + { key: "referenceHigh", required: false }, + { key: "panel", required: false }, + { key: "note", required: false }, +] as const; + +export const MEDICATION_FIELDS = [ + { key: "name", required: true }, + { key: "dose", required: true }, + { key: "times", required: false }, + { key: "asNeeded", required: false }, + { key: "startsOn", required: false }, + { key: "endsOn", required: false }, +] as const; + +export type FieldKey = + | (typeof LAB_FIELDS)[number]["key"] + | (typeof MEDICATION_FIELDS)[number]["key"]; + +export function fieldsFor(kind: RecordImportKind) { + return kind === "labs" ? LAB_FIELDS : MEDICATION_FIELDS; +} + +/** Header words per field, in the languages HealthLog speaks, folded. */ +const SYNONYMS: Record = { + analyte: [ + "analyte", + "marker", + "biomarker", + "test", + "parameter", + "analyt", + "laborwert", + "wert name", + "name", + "prueba", + "analyse", + "esame", + "badanie", + ], + value: [ + "value", + "result", + "wert", + "ergebnis", + "resultado", + "valor", + "valeur", + "resultat", + "valore", + "risultato", + "wynik", + "waarde", + "uitslag", + "결과", + "값", + ], + unit: [ + "unit", + "units", + "einheit", + "unidad", + "unite", + "unita", + "jednostka", + "eenheid", + "단위", + ], + date: [ + "date", + "datum", + "fecha", + "data", + "day", + "tag", + "taken", + "taken at", + "collected", + "abnahme", + "날짜", + ], + time: [ + "time", + "uhrzeit", + "zeit", + "hora", + "heure", + "ora", + "godzina", + "tijd", + "시간", + ], + referenceRange: [ + "reference", + "reference range", + "range", + "referenz", + "referenzbereich", + "normbereich", + "norm", + "rango", + "reference interval", + "intervalle", + "riferimento", + "zakres", + "referentie", + ], + referenceLow: ["low", "min", "lower", "untergrenze", "von", "ref low"], + referenceHigh: ["high", "max", "upper", "obergrenze", "bis", "ref high"], + panel: ["panel", "group", "gruppe", "profil", "category", "kategorie"], + note: [ + "note", + "notes", + "comment", + "kommentar", + "notiz", + "bemerkung", + "nota", + "remarque", + "uwagi", + "opmerking", + ], + name: [ + "name", + "medication", + "medicine", + "drug", + "medikament", + "arznei", + "präparat", + "praparat", + "wirkstoff", + "medicamento", + "medicament", + "farmaco", + "lek", + "medicijn", + "약", + ], + dose: [ + "dose", + "dosage", + "dosis", + "dosierung", + "strength", + "stärke", + "starke", + "dosage", + "dawka", + "용량", + ], + times: [ + "times", + "time", + "schedule", + "uhrzeit", + "uhrzeiten", + "einnahme", + "zeiten", + "horario", + "heures", + "orari", + "godziny", + "tijden", + "시간", + ], + asNeeded: [ + "as needed", + "prn", + "bei bedarf", + "bedarf", + "a demanda", + "si besoin", + "al bisogno", + "w razie potrzeby", + "zo nodig", + "필요시", + ], + startsOn: [ + "start", + "starts", + "start date", + "begin", + "beginn", + "seit", + "from", + "von", + "inicio", + "debut", + "inizio", + "od", + "begin datum", + ], + endsOn: [ + "end", + "ends", + "end date", + "until", + "bis", + "ende", + "fin", + "fine", + "do", + "einddatum", + ], +}; + +function fold(text: string): string { + return text + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[_\-./()]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Suggest a column per field from the header names. Exact synonym first, then + * a header that starts with one; a column is offered to one field at most, + * and the required fields choose first. + */ +export function guessMapping( + headers: readonly string[], + kind: RecordImportKind, +): Partial> { + const folded = headers.map(fold); + const taken = new Set(); + const out: Partial> = {}; + const order = [...fieldsFor(kind)].sort( + (a, b) => Number(b.required) - Number(a.required), + ); + for (const pass of ["exact", "prefix"] as const) { + for (const { key } of order) { + if (out[key] !== undefined) continue; + const words = SYNONYMS[key]; + const index = folded.findIndex( + (h, i) => + !taken.has(i) && + words.some((w) => + pass === "exact" ? h === w : h.startsWith(`${w} `), + ), + ); + if (index >= 0) { + out[key] = index; + taken.add(index); + } + } + } + return out; +} + +export interface FileHead { + delimiter: CsvDelimiter; + rows: string[][]; +} + +/** The first `maxRows` records of a file's head text. */ +export function readHead(text: string, maxRows = 6): FileHead { + const tokenizer = new CsvTokenizer("auto"); + const rows: string[][] = []; + for (const record of [...tokenizer.push(text), ...tokenizer.end()]) { + rows.push(record.fields); + if (rows.length >= maxRows) break; + } + return { delimiter: tokenizer.detectedDelimiter ?? ",", rows }; +} + +/** + * How the sample writes numbers and dates. A semicolon file almost always + * writes a decimal comma; a sample cell like `5,4` says so outright. The date + * order follows the first date-shaped cell: ISO, then a first number above 12 + * (day first) or a second above 12 (month first), then the separator habit + * (`.` is day first, `/` alone is left to the person: it stays ISO-default + * `dmy` only when a dot is seen). + */ +export function guessFormat( + head: FileHead, + dateColumn: number | undefined, +): { decimal: "dot" | "comma"; dateOrder: "iso" | "dmy" | "mdy" } { + const data = head.rows.slice(1); + const cells = data.flat(); + const commaNumber = cells.some((c) => /^-?\d+,\d{1,2}$/.test(c.trim())); + const decimal = head.delimiter === ";" || commaNumber ? "comma" : "dot"; + + let dateOrder: "iso" | "dmy" | "mdy" = "iso"; + const dates = data + .map((r) => (dateColumn === undefined ? "" : (r[dateColumn] ?? "").trim())) + .filter(Boolean); + for (const d of dates) { + if (/^\d{4}-\d{2}-\d{2}/.test(d)) { + dateOrder = "iso"; + break; + } + const m = /^(\d{1,2})([./-])(\d{1,2})\2\d{4}/.exec(d); + if (!m) continue; + if (Number(m[1]) > 12) { + dateOrder = "dmy"; + break; + } + if (Number(m[3]) > 12) { + dateOrder = "mdy"; + break; + } + dateOrder = m[2] === "." ? "dmy" : "mdy"; + } + return { decimal, dateOrder }; +} + +export const RECORD_IMPORT_TEMPLATES: Record = { + labs: [ + "analyte,value,unit,date,time,reference range,panel,note", + "HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,", + "Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,", + "Urine protein,negative,,2025-03-01,,,Urine,", + "", + ].join("\n"), + medications: [ + "name,dose,times,as needed,start,end", + "Ramipril,5 mg,08:00,,2025-01-01,", + "Metformin,500 mg,08:00 20:00,,,", + "Ibuprofen,400 mg,,yes,,", + "", + ].join("\n"), +}; diff --git a/src/lib/import/csv-stream.ts b/src/lib/import/csv-stream.ts new file mode 100644 index 000000000..cd95011d5 --- /dev/null +++ b/src/lib/import/csv-stream.ts @@ -0,0 +1,267 @@ +/** + * A streaming CSV reader for the record imports (#1215). + * + * The measurement importer reads its whole body into one string and splits + * it. That is fine for its 16 MB cap, but a lab or medication history kept + * over years in a spreadsheet arrives as one upload, and the import must hold + * a bounded amount of it at a time. This reader consumes the body chunk by + * chunk and hands out one record at a time; at any moment it holds the bytes + * of the record it is building and nothing before it. + * + * RFC 4180 plus what spreadsheets actually write: + * - a UTF-8 byte-order mark at the start is dropped; + * - the separator is `,`, `;` or a tab, detected from the header line + * outside quotes unless the caller names one (a German export writes `;` + * because `,` is its decimal mark); + * - fields may be quoted with `"`, a doubled `""` inside is one quote, and + * a quoted field may hold the separator and line breaks; + * - lines end in `\n`, `\r\n` or a lone `\r`; + * - a record of only empty fields (a trailing blank line) is skipped. + * + * Bounds: the body is counted while it is read and refused past `maxBytes`; + * a field longer than `MAX_FIELD_CHARS` or a record wider than + * `MAX_COLUMNS` marks that record `oversized` instead of growing without end + * (one runaway quote would otherwise swallow the rest of the file). + */ + +export type CsvDelimiter = "," | ";" | "\t"; + +export const MAX_FIELD_CHARS = 4000; +export const MAX_COLUMNS = 100; + +export interface CsvRecord { + /** 1-based physical line the record starts on. */ + line: number; + fields: string[]; + /** A field or the record ran past the bounds; its fields are cut short. */ + oversized: boolean; +} + +/** Count separators outside quotes in a header line; the most frequent wins. */ +export function detectDelimiter(headerLine: string): CsvDelimiter { + const counts: Record = { ",": 0, ";": 0, "\t": 0 }; + let quoted = false; + for (const ch of headerLine) { + if (ch === '"') quoted = !quoted; + else if (!quoted && (ch === "," || ch === ";" || ch === "\t")) { + counts[ch] += 1; + } + } + // Ties go to the comma, then the semicolon: a one-column file is a comma file. + let best: CsvDelimiter = ","; + for (const d of [";", "\t"] as const) { + if (counts[d] > counts[best]) best = d; + } + return best; +} + +/** + * The incremental tokenizer. `push` takes the next piece of text and returns + * the records it completed; `end` flushes the last one. Pure and synchronous, + * so it is fuzzed without streams. + */ +export class CsvTokenizer { + private delimiter: CsvDelimiter | null; + private field = ""; + private fields: string[] = []; + private quoted = false; + /** The previous character closed a quote, or was a quote inside quotes. */ + private quotePending = false; + private afterCr = false; + private line = 1; + private recordLine = 1; + private oversized = false; + private started = false; + /** Text held back until the header line shows the separator. */ + private pending = ""; + + constructor(delimiter: CsvDelimiter | "auto" = "auto") { + this.delimiter = delimiter === "auto" ? null : delimiter; + } + + get detectedDelimiter(): CsvDelimiter | null { + return this.delimiter; + } + + push(text: string): CsvRecord[] { + if (!this.started && text.length > 0) { + this.started = true; + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + } + if (this.delimiter === null) { + this.pending += text; + const end = firstLineEnd(this.pending); + if (end === -1) { + // Bound the hold-back too: a header that never ends is a broken file. + if (this.pending.length <= MAX_FIELD_CHARS * MAX_COLUMNS) return []; + this.delimiter = detectDelimiter(this.pending); + } else { + this.delimiter = detectDelimiter(this.pending.slice(0, end)); + } + text = this.pending; + this.pending = ""; + } + const out: CsvRecord[] = []; + for (let i = 0; i < text.length; i++) { + this.consume(text[i], out); + } + return out; + } + + end(): CsvRecord[] { + const out: CsvRecord[] = []; + if (this.delimiter === null) { + const held = this.pending; + this.pending = ""; + this.delimiter = detectDelimiter(held); + for (const ch of held) this.consume(ch, out); + } + if (this.quotePending) { + // A closing quote right at the end of the input. + this.quoted = false; + this.quotePending = false; + } + if (this.field !== "" || this.fields.length > 0) this.finishRecord(out); + return out; + } + + private append(ch: string) { + if (this.field.length < MAX_FIELD_CHARS) this.field += ch; + else this.oversized = true; + } + + private finishField() { + if (this.fields.length < MAX_COLUMNS) this.fields.push(this.field); + else this.oversized = true; + this.field = ""; + } + + private finishRecord(out: CsvRecord[]) { + this.finishField(); + const blank = this.fields.every((f) => f === ""); + if (!blank) { + out.push({ + line: this.recordLine, + fields: this.fields, + oversized: this.oversized, + }); + } + this.fields = []; + this.oversized = false; + } + + private consume(ch: string, out: CsvRecord[]) { + // A `\n` straight after a `\r` belongs to the same line end. + if (this.afterCr) { + this.afterCr = false; + if (ch === "\n") return; + } + if (this.quoted) { + if (this.quotePending) { + this.quotePending = false; + if (ch === '"') { + this.append('"'); + return; + } + // The quote closed the field; fall through to unquoted handling. + this.quoted = false; + } else if (ch === '"') { + this.quotePending = true; + return; + } else { + if (ch === "\n" || ch === "\r") { + this.line += 1; + if (ch === "\r") this.afterCr = true; + this.append("\n"); + return; + } + this.append(ch); + return; + } + } + if (ch === this.delimiter) { + this.finishField(); + return; + } + if (ch === "\n" || ch === "\r") { + this.finishRecord(out); + this.line += 1; + this.recordLine = this.line; + if (ch === "\r") this.afterCr = true; + return; + } + if (ch === '"' && this.field === "") { + this.quoted = true; + return; + } + this.append(ch); + } +} + +function firstLineEnd(text: string): number { + let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') quoted = !quoted; + else if (!quoted && (ch === "\n" || ch === "\r")) return i; + } + return -1; +} + +export class CsvTooLargeError extends Error { + constructor(readonly maxBytes: number) { + super(`CSV exceeds ${maxBytes} bytes`); + this.name = "CsvTooLargeError"; + } +} + +/** + * Read a byte stream as CSV records, one at a time. The bytes are counted as + * they arrive and the read stops with `CsvTooLargeError` past `maxBytes`, so + * a body without a Content-Length cannot run past the cap either. + */ +export async function* readCsvRecords( + stream: ReadableStream, + opts: { maxBytes: number; delimiter?: CsvDelimiter | "auto" }, +): AsyncGenerator { + const tokenizer = new CsvTokenizer(opts.delimiter ?? "auto"); + const decoder = new TextDecoder("utf-8"); + const reader = stream.getReader(); + let bytes = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > opts.maxBytes) throw new CsvTooLargeError(opts.maxBytes); + for (const record of tokenizer.push( + decoder.decode(value, { stream: true }), + )) { + yield record; + } + } + for (const record of tokenizer.push(decoder.decode())) yield record; + for (const record of tokenizer.end()) yield record; + } finally { + reader.releaseLock(); + } +} + +/** A string as a one-chunk-at-a-time stream; for tests and the client preview. */ +export function textToStream( + text: string, + chunkSize = 64 * 1024, +): ReadableStream { + const bytes = new TextEncoder().encode(text); + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= bytes.length) { + controller.close(); + return; + } + controller.enqueue(bytes.slice(offset, offset + chunkSize)); + offset += chunkSize; + }, + }); +} diff --git a/src/lib/import/csv-values.ts b/src/lib/import/csv-values.ts new file mode 100644 index 000000000..d48ded1ec --- /dev/null +++ b/src/lib/import/csv-values.ts @@ -0,0 +1,230 @@ +/** + * Reading numbers and dates out of spreadsheet cells (#1215). + * + * Conservative by rule: a cell that could mean two things is refused with + * its own reason rather than read one way. The person sees the line and the + * reason and fixes the file or the setting; a wrong number stored quietly is + * the one outcome an import must never have. + */ +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; + +export type DecimalMark = "dot" | "comma"; +export type DateOrder = "iso" | "dmy" | "mdy"; + +export type NumberResult = + | { ok: true; value: number } + | { ok: false; reason: "invalid_number" | "ambiguous_number" }; + +/** + * A number written with the file's decimal mark. Grouping separators are + * accepted only in the unmistakable form (`1.234,5` with a comma mark, + * `1,234.5` with a dot mark). A lone grouping-shaped number in the other + * convention (`1,234` in a dot file, `1.234` in a comma file) is ambiguous: + * it is a thousand and something in one country and one point something in + * the next, and lab values live exactly in that range. + */ +export function parseLocaleNumber( + raw: string, + mark: DecimalMark, +): NumberResult { + let s = raw.trim().replace(/[   ]/g, ""); + let sign = 1; + if (s.startsWith("-") || s.startsWith("−")) { + sign = -1; + s = s.slice(1); + } else if (s.startsWith("+")) { + s = s.slice(1); + } + if (!/^[\d.,]+$/.test(s)) return { ok: false, reason: "invalid_number" }; + const dec = mark === "comma" ? "," : "."; + const grp = mark === "comma" ? "." : ","; + const e = (c: string) => `\\${c}`; + const read = (digits: string) => { + const value = sign * Number(digits); + return Number.isFinite(value) + ? ({ ok: true, value } as const) + : ({ ok: false, reason: "invalid_number" } as const); + }; + + // Only the file's own decimal mark, at most once. + if (new RegExp(`^(\\d+(${e(dec)}\\d+)?|${e(dec)}\\d+)$`).test(s)) { + return read(s.replace(dec, ".")); + } + // Grouped in the file's own convention: 1,234,567 / 1,234.5 (dot file). + if (new RegExp(`^\\d{1,3}(${e(grp)}\\d{3})+(${e(dec)}\\d+)?$`).test(s)) { + const groups = s.split(grp).length - 1; + // One group and no decimal part reads as the other convention's decimal + // too (1,234 = one point two three four in a comma country). + if (groups === 1 && !s.includes(dec)) { + return { ok: false, reason: "ambiguous_number" }; + } + return read(s.split(grp).join("").replace(dec, ".")); + } + // One mark of the other convention with not exactly three digits after it + // can only be a decimal mark (5,4 in a dot file, 5.4 in a comma file). + const other = new RegExp(`^\\d+${e(grp)}(\\d+)$`).exec(s); + if (other) { + if (other[1].length === 3) return { ok: false, reason: "ambiguous_number" }; + return read(s.replace(grp, ".")); + } + return { ok: false, reason: "invalid_number" }; +} + +export type DateResult = + | { + ok: true; + /** The instant to store. */ + instant: Date; + /** The calendar date the cell states. */ + dateKey: string; + /** The cell gave a date without a time of day. */ + dateOnly: boolean; + } + | { + ok: false; + reason: "invalid_date" | "invalid_time" | "ambiguous_date"; + }; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** + * A date cell (and an optional separate time cell) in the stated order. + * + * - `iso`: `YYYY-MM-DD`, optionally with `T`/space and `HH:mm[:ss]` and an + * offset (`Z`, `+02:00`), the shape every export tool can write; + * - `dmy`: `DD.MM.YYYY`, `DD/MM/YYYY` or `DD-MM-YYYY`; + * - `mdy`: `MM/DD/YYYY`. + * + * A time without an offset is a wall-clock time in `timezone` (the account's + * zone unless the import names another). A date without a time is stored as + * the date-only anchor the scanned and document readings use (noon UTC), so + * it is the same calendar date for everyone who opens the record. + * + * A two-digit year is refused as ambiguous. So is a `dmy`/`mdy` cell whose + * first number cannot be what the order says (13/02/2026 under `mdy`): + * that file is in the other order, and guessing it per row would mix both. + */ +export function parseCsvDate( + rawDate: string, + rawTime: string | undefined, + opts: { order: DateOrder; timezone: string }, +): DateResult { + const cell = rawDate.trim(); + if (cell === "") return { ok: false, reason: "invalid_date" }; + let y: number; + let m: number; + let d: number; + let timePart: string | undefined; + let offset: string | undefined; + + if (opts.order === "iso") { + const match = + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)\s*(Z|[+-]\d{2}:?\d{2})?)?$/i.exec( + cell, + ); + if (!match) { + return /^\d{2}-\d{2}-\d{2}$/.test(cell) + ? { ok: false, reason: "ambiguous_date" } + : { ok: false, reason: "invalid_date" }; + } + y = Number(match[1]); + m = Number(match[2]); + d = Number(match[3]); + timePart = match[4]; + offset = match[5]; + } else { + const match = + /^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})(?:[ T](\d{1,2}:\d{2}(?::\d{2})?))?$/.exec( + cell, + ); + if (!match) return { ok: false, reason: "invalid_date" }; + if (match[3].length === 2) return { ok: false, reason: "ambiguous_date" }; + const first = Number(match[1]); + const second = Number(match[2]); + y = Number(match[3]); + [d, m] = opts.order === "dmy" ? [first, second] : [second, first]; + if (m > 12 && d <= 12) return { ok: false, reason: "ambiguous_date" }; + timePart = match[4]; + } + + const dateKey = `${String(y).padStart(4, "0")}-${pad(m)}-${pad(d)}`; + if (!isCalendarDateKey(dateKey)) return { ok: false, reason: "invalid_date" }; + + const time = (rawTime ?? "").trim() || timePart; + if (!time) { + return { + ok: true, + instant: new Date(`${dateKey}T12:00:00.000Z`), + dateKey, + dateOnly: true, + }; + } + const tm = /^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/.exec(time); + if (!tm) return { ok: false, reason: "invalid_time" }; + const hour = Number(tm[1]); + const minute = Number(tm[2]); + const second = Number(tm[3] ?? 0); + if (hour > 23 || minute > 59 || second > 59) { + return { ok: false, reason: "invalid_time" }; + } + if (offset) { + const norm = + offset.toUpperCase() === "Z" + ? "Z" + : offset.replace(/^([+-]\d{2})(\d{2})$/, "$1:$2"); + const instant = new Date( + `${dateKey}T${pad(hour)}:${pad(minute)}:${pad(second)}${norm}`, + ); + if (Number.isNaN(instant.getTime())) + return { ok: false, reason: "invalid_time" }; + return { ok: true, instant, dateKey, dateOnly: false }; + } + const instant = zonedWallClockToUtc( + { year: y, month: m, day: d, hour, minute, second }, + opts.timezone, + ); + return { ok: true, instant, dateKey, dateOnly: false }; +} + +/** A yes/no cell in the languages HealthLog speaks; anything else is null. */ +export function parseYesNo(raw: string): boolean | null { + const v = raw.trim().toLowerCase(); + if (v === "") return null; + if ( + [ + "1", + "yes", + "y", + "true", + "x", + "ja", + "j", + "sí", + "si", + "oui", + "tak", + "예", + "wahr", + ].includes(v) + ) { + return true; + } + if ( + [ + "0", + "no", + "n", + "false", + "nein", + "non", + "nie", + "nee", + "아니오", + "falsch", + ].includes(v) + ) { + return false; + } + return null; +} diff --git a/src/lib/import/labs-csv.ts b/src/lib/import/labs-csv.ts new file mode 100644 index 000000000..86e4b202c --- /dev/null +++ b/src/lib/import/labs-csv.ts @@ -0,0 +1,552 @@ +/** + * CSV import of lab results (#1215). + * + * A row becomes a lab reading the way a scanned report's confirmed row does + * (`/api/labs/ocr/commit`): the name resolves to the account's marker by the + * same index (name, alias, catalog), a marker is minted from the first row + * that names a new analyte, a reading in another unit is converted when the + * conversion is known and kept beside what the file said, and a reading the + * record already holds is skipped. Nothing about a row is guessed: an + * ambiguous number or date, a numeric value without a unit, or a unit that + * does not convert into the marker's is refused with its reason and line. + * + * Memory: the file is read record by record (`readCsvRecords`) and written in + * chunks of `CHUNK_ROWS`; what is held across chunks is the marker index, the + * duplicate keys of rows already accepted and the bounded report. + */ +import { prisma } from "@/lib/db"; +import { + readCsvRecords, + type CsvDelimiter, + type CsvRecord, +} from "@/lib/import/csv-stream"; +import { parseCsvDate, parseLocaleNumber } from "@/lib/import/csv-values"; +import { RecordImportReport } from "@/lib/import/record-import-report"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { + addToIndex, + loadMarkerIndex, + resolveInIndex, + unitFitsMarker, + type IndexedMarker, + type MarkerIndex, +} from "@/lib/labs/biomarker-resolve"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; +import { + labReadingDay, + labReadingDaySearchRange, +} from "@/lib/labs/reading-day"; +import { encryptNoteToBytes } from "@/lib/labs/store"; +import type { + LabsCsvQuery, + RecordImportResult, + RecordImportRowReason, +} from "@/lib/validations/record-import"; + +export const LABS_CSV_MAX_ROWS = 20_000; +const CHUNK_ROWS = 200; +const MAX_LISTED_MARKERS = 200; +/** A reading dated more than a day ahead is a typing mistake, not a result. */ +const FUTURE_SLACK_MS = 86_400_000; +const EARLIEST = Date.UTC(1900, 0, 1); + +export const LAB_TEXT_LIMITS = { + analyte: 200, + valueText: 200, + unit: 80, + panel: 100, + note: 1000, +} as const; + +export interface LabCsvRow { + line: number; + analyte: string; + value: number | null; + valueText: string | null; + unit: string | null; + takenAt: Date; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + sourceReferenceText: string | null; + panel: string | null; + note: string | null; +} + +export type LabRowResult = + | { ok: true; row: LabCsvRow } + | { ok: false; reason: RecordImportRowReason; field: string }; + +function cell(fields: string[], index: number | undefined): string { + if (index === undefined) return ""; + return (fields[index] ?? "").trim(); +} + +/** One CSV record → a lab row, or the first reason it cannot be one. Pure. */ +export function normaliseLabRow( + record: Pick, + query: LabsCsvQuery, + timezone: string, + now: Date = new Date(), +): LabRowResult { + const f = record.fields; + const fail = ( + reason: RecordImportRowReason, + field: string, + ): LabRowResult => ({ ok: false, reason, field }); + + const analyte = cell(f, query.analyte); + if (!analyte) return fail("missing_value", "analyte"); + if (analyte.length > LAB_TEXT_LIMITS.analyte) + return fail("text_too_long", "analyte"); + + const rawValue = cell(f, query.value); + if (!rawValue) return fail("missing_value", "value"); + let value: number | null = null; + let valueText: string | null = null; + const parsed = parseLocaleNumber(rawValue, query.decimal); + if (parsed.ok) { + value = parsed.value; + } else if (parsed.reason === "ambiguous_number") { + return fail("ambiguous_number", "value"); + } else if (/^[+-]?[\d.,\s]+$/.test(rawValue)) { + // Digits and marks that still do not read as a number: a broken number, + // not a qualitative result. + return fail("invalid_number", "value"); + } else { + if (rawValue.length > LAB_TEXT_LIMITS.valueText) + return fail("text_too_long", "value"); + valueText = rawValue; + } + + const unit = cell(f, query.unit) || null; + if (value !== null && !unit) return fail("missing_unit", "unit"); + if (unit && unit.length > LAB_TEXT_LIMITS.unit) + return fail("text_too_long", "unit"); + + const date = parseCsvDate( + cell(f, query.date), + query.time === undefined ? undefined : cell(f, query.time), + { + order: query.dateOrder, + timezone, + }, + ); + if (!date.ok) return fail(date.reason, "date"); + if (date.instant.getTime() > now.getTime() + FUTURE_SLACK_MS) + return fail("future_date", "date"); + if (date.instant.getTime() < EARLIEST) return fail("invalid_date", "date"); + + let low: number | null = null; + let high: number | null = null; + let rangeText: string | null = null; + for (const [key, index] of [ + ["referenceLow", query.referenceLow], + ["referenceHigh", query.referenceHigh], + ] as const) { + const raw = cell(f, index); + if (!raw) continue; + const n = parseLocaleNumber(raw, query.decimal); + if (!n.ok) + return fail( + n.reason === "ambiguous_number" + ? "ambiguous_number" + : "invalid_reference_range", + key, + ); + if (key === "referenceLow") low = n.value; + else high = n.value; + } + const rawRange = cell(f, query.referenceRange); + if (rawRange) { + // A comma-decimal file writes ranges as "4,0 - 5,6"; the range reader + // speaks the dot, so the marks are swapped first for such a file. + const forReader = + query.decimal === "comma" + ? rawRange.replace(/(\d),(\d)/g, "$1.$2") + : rawRange; + const printed = parseReferenceRange(forReader, unit); + if (printed) { + low ??= printed.low; + high ??= printed.high; + rangeText = rawRange.replace(/\s+/g, " ").slice(0, 120); + } + } + if (low !== null && high !== null && low > high) { + return fail("invalid_reference_range", "referenceRange"); + } + + const panel = cell(f, query.panel) || null; + if (panel && panel.length > LAB_TEXT_LIMITS.panel) + return fail("text_too_long", "panel"); + const note = cell(f, query.note) || null; + if (note && note.length > LAB_TEXT_LIMITS.note) + return fail("text_too_long", "note"); + + return { + ok: true, + row: { + line: record.line, + analyte, + value, + valueText, + unit, + takenAt: date.instant, + sourceReferenceLow: valueText === null ? low : null, + sourceReferenceHigh: valueText === null ? high : null, + sourceReferenceText: valueText === null ? rangeText : null, + panel, + note, + }, + }; +} + +/** + * The keys a reading is a duplicate under, mirroring the scan commit's + * `isDuplicate`: the marker (or the name, for a reading filed without one), + * the reading's calendar day, and the value as stored, as printed, or as text. + */ +export function readingKeys( + reading: { + biomarkerId: string | null; + analyte: string; + takenAt: Date; + value: number | null; + valueText: string | null; + sourceValue?: number | null; + sourceUnit?: string | null; + unit?: string | null; + }, + tz: string, +): string[] { + const who = reading.biomarkerId + ? `bm:${reading.biomarkerId}` + : `an:${reading.analyte.trim().toLowerCase()}`; + const day = labReadingDay(reading.takenAt, tz); + const keys: string[] = []; + if (reading.value !== null) keys.push(`${who}|${day}|n:${reading.value}`); + if ( + reading.sourceValue !== null && + reading.sourceValue !== undefined && + reading.sourceUnit + ) { + keys.push( + `${who}|${day}|s:${reading.sourceValue}:${reading.sourceUnit.trim().toLowerCase()}`, + ); + } + if (reading.valueText !== null) { + keys.push(`${who}|${day}|t:${reading.valueText.trim().toLowerCase()}`); + } + return keys; +} + +interface Planned { + row: LabCsvRow; + marker: IndexedMarker | null; +} + +function displayRow(row: LabCsvRow, marker: string) { + return { + line: row.line, + marker, + value: row.value, + valueText: row.valueText, + unit: row.unit, + takenAt: row.takenAt.toISOString(), + referenceLow: row.sourceReferenceLow, + referenceHigh: row.sourceReferenceHigh, + panel: row.panel, + note: row.note !== null, + }; +} + +/** + * Read, check and (unless a dry run) write a lab CSV. Throws + * `CsvTooLargeError` past the byte cap; everything else is reported per row. + */ +export async function importLabsCsv(args: { + userId: string; + tz: string; + stream: ReadableStream; + maxBytes: number; + query: LabsCsvQuery; + now?: Date; +}): Promise { + const { userId, tz, query } = args; + const dryRun = query.dryRun; + const timezone = query.timezone ?? tz; + const report = new RecordImportReport("labs", dryRun); + const index: MarkerIndex = await loadMarkerIndex(prisma, userId); + // A dry run works on a copy, so the markers it pretends to mint never leak. + const working: MarkerIndex = dryRun + ? { markers: [...index.markers], aliases: [...index.aliases] } + : index; + const matched = new Map< + string, + { name: string; marker: string; how: "name" | "alias" | "catalog" } + >(); + const created = new Set(); + const seen = new Set(); + let chunk: LabCsvRow[] = []; + let pendingId = 0; + + const delimiter: CsvDelimiter | "auto" = + query.delimiter === "comma" + ? "," + : query.delimiter === "semicolon" + ? ";" + : query.delimiter === "tab" + ? "\t" + : "auto"; + + const flush = async () => { + if (chunk.length === 0) return; + const rows = chunk; + chunk = []; + + // 1. Resolve every row's marker; a dry run pretends to mint. + const planned: Planned[] = []; + for (const row of rows) { + const hit = resolveInIndex( + working, + row.analyte, + row.valueText === null ? row.unit : null, + ); + let marker: IndexedMarker | null = hit?.marker ?? null; + if (hit) { + // Listed once per spelling, so the person sees which of the file's + // names landed on which of their markers. + const key = row.analyte.toLowerCase(); + if (!matched.has(key) && matched.size < MAX_LISTED_MARKERS) { + matched.set(key, { + name: row.analyte, + marker: hit.marker.name, + how: hit.matchKind, + }); + } + } else if (dryRun) { + const unit = row.unit ?? ""; + marker = { + id: `pending:${pendingId++}`, + name: row.analyte, + unit, + lowerBound: row.sourceReferenceLow, + upperBound: row.sourceReferenceHigh, + panel: row.panel, + analyteKey: matchAnalyteKey({ name: row.analyte, unit }), + hidden: false, + createdAt: new Date(), + }; + addToIndex(working, marker); + if (created.size < MAX_LISTED_MARKERS) created.add(row.analyte); + } + // The unit must fit the marker, or convert into it. + if ( + marker && + row.value !== null && + // The key as the write derives it (`ensureAnalyteKey`), so the check + // and the conversion the import then runs cannot disagree. + !unitFitsMarker( + { + unit: marker.unit, + analyteKey: + matchAnalyteKey({ name: marker.name, unit: marker.unit }) ?? + marker.analyteKey, + }, + row.unit, + null, + ) + ) { + report.fail(row.line, "unit_mismatch", "unit"); + continue; + } + planned.push({ row, marker }); + } + + // 2. Duplicates against the record, one query for the chunk. + const days = planned.map((p) => labReadingDay(p.row.takenAt, tz)).sort(); + const existingKeys = new Set(); + if (planned.length > 0) { + const from = labReadingDaySearchRange(days[0]).gte; + const to = labReadingDaySearchRange(days[days.length - 1]).lt; + const ids = [ + ...new Set( + planned + .map((p) => p.marker?.id) + .filter((id): id is string => !!id && !id.startsWith("pending:")), + ), + ]; + const names = [...new Set(planned.map((p) => p.row.analyte))]; + const existing = await prisma.labResult.findMany({ + where: { + userId, + deletedAt: null, + takenAt: { gte: from, lt: to }, + OR: [{ biomarkerId: { in: ids } }, { analyte: { in: names } }], + }, + select: { + biomarkerId: true, + analyte: true, + takenAt: true, + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + }, + }); + for (const e of existing) { + for (const k of readingKeys(e, tz)) existingKeys.add(k); + // A reading filed under a marker also answers to its printed name. + if (e.biomarkerId) { + for (const k of readingKeys({ ...e, biomarkerId: null }, tz)) + existingKeys.add(k); + } + } + } + + // 3. Write (or count) what is new. + for (const { row, marker } of planned) { + const probe = { + biomarkerId: + marker && !marker.id.startsWith("pending:") ? marker.id : null, + analyte: row.analyte, + takenAt: row.takenAt, + value: row.value, + valueText: row.valueText, + sourceValue: row.value, + sourceUnit: row.unit, + }; + const keys = [ + ...readingKeys(probe, tz), + ...readingKeys({ ...probe, biomarkerId: null }, tz), + ...(marker + ? readingKeys({ ...probe, biomarkerId: marker.id }, tz) + : []), + ]; + if (keys.some((k) => existingKeys.has(k) || seen.has(k))) { + report.duplicate(row.line); + continue; + } + + if (dryRun) { + for (const k of keys) seen.add(k); + report.valid += 1; + report.inserted += 1; + report.addPreview(displayRow(row, marker?.name ?? row.analyte)); + continue; + } + + const biomarker = + marker ?? + (await resolveOrMintBiomarker( + userId, + { + analyte: row.analyte, + unit: row.unit ?? "", + referenceLow: + row.valueText === null ? row.sourceReferenceLow : null, + referenceHigh: + row.valueText === null ? row.sourceReferenceHigh : null, + panel: row.panel, + }, + { index: working }, + )); + if (!marker) created.add(row.analyte); + + let storedValue = row.value; + let storedLow = row.sourceReferenceLow; + let storedHigh = row.sourceReferenceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = + null; + if (row.value !== null) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: row.value, + unit: row.unit ?? undefined, + sourceReferenceLow: storedLow, + sourceReferenceHigh: storedHigh, + }); + if (!reading.ok) { + report.fail(row.line, "unit_mismatch", "unit"); + continue; + } + storedValue = reading.value; + storedLow = reading.sourceReferenceLow; + storedHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } + } + + // Field by field, never a spread of the parsed row. + await prisma.labResult.create({ + data: { + userId, + biomarkerId: biomarker.id, + panel: row.panel ?? biomarker.panel, + analyte: biomarker.name, + value: storedValue, + valueText: row.valueText, + unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, + referenceLow: biomarker.lowerBound, + referenceHigh: biomarker.upperBound, + sourceReferenceLow: storedLow, + sourceReferenceHigh: storedHigh, + sourceReferenceText: row.sourceReferenceText, + takenAt: row.takenAt, + source: "IMPORT", + noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null, + }, + }); + for (const k of [ + ...keys, + ...readingKeys({ ...probe, biomarkerId: biomarker.id }, tz), + ]) + seen.add(k); + report.valid += 1; + report.inserted += 1; + } + }; + + let first = true; + for await (const record of readCsvRecords(args.stream, { + maxBytes: args.maxBytes, + delimiter, + })) { + if (first && query.header) { + first = false; + continue; + } + first = false; + report.rows += 1; + if (report.rows > LABS_CSV_MAX_ROWS) { + report.fail(record.line, "row_limit"); + continue; + } + if (record.oversized) { + report.fail(record.line, "oversized_row"); + continue; + } + const result = normaliseLabRow(record, query, timezone, args.now); + if (!result.ok) { + report.fail(record.line, result.reason, result.field); + continue; + } + chunk.push(result.row); + if (chunk.length >= CHUNK_ROWS) await flush(); + } + await flush(); + + return report.toResult({ + markers: { + matched: [...matched.values()], + created: [...created].slice(0, MAX_LISTED_MARKERS), + }, + }); +} diff --git a/src/lib/import/medications-csv.ts b/src/lib/import/medications-csv.ts new file mode 100644 index 000000000..d616ce675 --- /dev/null +++ b/src/lib/import/medications-csv.ts @@ -0,0 +1,331 @@ +/** + * CSV import of medications (#1215). + * + * Conservative by design: a row maps only what is unambiguous in a + * spreadsheet and in the medication model alike. Name and dose (as written), + * daily intake times, an as-needed flag, and a start and end date. Every other + * rhythm (weekdays, every other week, cycles, injections) is set up in the + * app afterwards, where the person sees what it means. A row needs either + * times or the as-needed mark: the medication model holds no scheduled + * medication without a time, and an import does not invent one. + * + * Every row is checked against `createMedicationSchema`, the schema the + * create route parses with, so an import can never store a medication the + * form could not. Reminders stay off unless the import asks for them, so an + * upload never starts notifying on its own. A medication the record already + * holds under the same name and dose, or an earlier row of the file, is + * skipped. + */ +import { prisma } from "@/lib/db"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + readCsvRecords, + type CsvDelimiter, + type CsvRecord, +} from "@/lib/import/csv-stream"; +import { parseCsvDate, parseYesNo } from "@/lib/import/csv-values"; +import { RecordImportReport } from "@/lib/import/record-import-report"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { setMedicationCategory } from "@/lib/medication-category"; +import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { + dateOfDayKey, + dayKeyOfDate, + validateCourses, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; +import { createMedicationSchema } from "@/lib/validations/medication"; +import type { + MedicationsCsvQuery, + RecordImportResult, + RecordImportRowReason, +} from "@/lib/validations/record-import"; + +export const MEDICATIONS_CSV_MAX_ROWS = 500; + +export interface MedicationCsvRow { + line: number; + name: string; + dose: string; + times: string[]; + asNeeded: boolean; + startsOn: string | null; + endsOn: string | null; +} + +export type MedicationRowResult = + | { ok: true; row: MedicationCsvRow } + | { ok: false; reason: RecordImportRowReason; field: string }; + +function cell(fields: string[], index: number | undefined): string { + if (index === undefined) return ""; + return (fields[index] ?? "").trim(); +} + +/** `8:00`, `08:00 20:00`, `08:00|20:00`, `8.00; 20.00` → sorted `HH:mm`s. */ +export function parseTimes(raw: string): string[] | null { + if (raw === "") return []; + const parts = raw.split(/[\s,;|/]+/).filter(Boolean); + const out = new Set(); + for (const part of parts) { + const m = /^(\d{1,2})[:.](\d{2})$/.exec(part); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + out.add(`${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`); + } + const sorted = [...out].sort(); + return sorted.length > 8 ? null : sorted; +} + +/** One record → a medication row, or the first reason it cannot be one. Pure. */ +export function normaliseMedicationRow( + record: Pick, + query: MedicationsCsvQuery, + timezone: string, +): MedicationRowResult { + const f = record.fields; + const fail = ( + reason: RecordImportRowReason, + field: string, + ): MedicationRowResult => ({ ok: false, reason, field }); + + const name = cell(f, query.name); + if (!name) return fail("missing_value", "name"); + if (name.length > 100) return fail("text_too_long", "name"); + const dose = cell(f, query.dose); + if (!dose) return fail("missing_value", "dose"); + if (dose.length > 50) return fail("text_too_long", "dose"); + + const times = parseTimes(cell(f, query.times)); + if (times === null) return fail("invalid_times", "times"); + + let asNeeded = false; + const rawAsNeeded = cell(f, query.asNeeded); + if (rawAsNeeded) { + const yes = parseYesNo(rawAsNeeded); + if (yes === null) return fail("invalid_yes_no", "asNeeded"); + asNeeded = yes; + } + if (asNeeded && times.length > 0) + return fail("as_needed_with_times", "times"); + if (!asNeeded && times.length === 0) return fail("missing_times", "times"); + + const day = (field: "startsOn" | "endsOn") => { + const raw = cell(f, query[field]); + if (!raw) return { ok: true as const, key: null }; + const parsed = parseCsvDate(raw, undefined, { + order: query.dateOrder, + timezone, + }); + if (!parsed.ok) return { ok: false as const, reason: parsed.reason }; + return { ok: true as const, key: parsed.dateKey }; + }; + const start = day("startsOn"); + if (!start.ok) return fail(start.reason, "startsOn"); + const end = day("endsOn"); + if (!end.ok) return fail(end.reason, "endsOn"); + if (start.key && end.key && end.key < start.key) { + return fail("end_before_start", "endsOn"); + } + + return { + ok: true, + row: { + line: record.line, + name, + dose, + times, + asNeeded, + startsOn: start.key, + endsOn: end.key, + }, + }; +} + +/** The create body this row stands for, exactly as the form would send it. */ +export function medicationCreateBody( + row: MedicationCsvRow, + reminders: boolean, +) { + const windowEnd = (() => { + if (row.times.length > 1) return row.times[row.times.length - 1]; + const [h, m] = (row.times[0] ?? "08:00").split(":").map(Number); + return `${String((h + 1) % 24).padStart(2, "0")}:${String(m).padStart(2, "0")}`; + })(); + return { + name: row.name, + dose: row.dose, + asNeeded: row.asNeeded, + notificationsEnabled: reminders && row.times.length > 0, + ...(row.startsOn ? { startsOn: row.startsOn } : {}), + ...(row.endsOn ? { endsOn: row.endsOn } : {}), + ...(row.times.length > 0 + ? { + schedules: [ + { + windowStart: row.times[0], + windowEnd, + timesOfDay: row.times, + }, + ], + } + : {}), + }; +} + +const identity = (name: string, dose: string) => + `${foldForMatch(name)}|${foldForMatch(dose).replace(/\s+/g, "")}`; + +export async function importMedicationsCsv(args: { + userId: string; + tz: string; + stream: ReadableStream; + maxBytes: number; + query: MedicationsCsvQuery; + now?: Date; +}): Promise { + const { userId, tz, query } = args; + const dryRun = query.dryRun; + const timezone = query.timezone ?? tz; + const report = new RecordImportReport("medications", dryRun); + const todayKey = userDayKey(args.now ?? new Date(), tz); + + const existing = await prisma.medication.findMany({ + where: { userId }, + select: { name: true, dose: true }, + }); + const seen = new Set(existing.map((m) => identity(m.name, m.dose))); + + const delimiter: CsvDelimiter | "auto" = + query.delimiter === "comma" + ? "," + : query.delimiter === "semicolon" + ? ";" + : query.delimiter === "tab" + ? "\t" + : "auto"; + + let first = true; + for await (const record of readCsvRecords(args.stream, { + maxBytes: args.maxBytes, + delimiter, + })) { + if (first && query.header) { + first = false; + continue; + } + first = false; + report.rows += 1; + if (report.rows > MEDICATIONS_CSV_MAX_ROWS) { + report.fail(record.line, "row_limit"); + continue; + } + if (record.oversized) { + report.fail(record.line, "oversized_row"); + continue; + } + const result = normaliseMedicationRow(record, query, timezone); + if (!result.ok) { + report.fail(record.line, result.reason, result.field); + continue; + } + const row = result.row; + const parsed = createMedicationSchema.safeParse( + medicationCreateBody(row, query.reminders), + ); + if (!parsed.success) { + report.fail(row.line, "invalid_medication"); + continue; + } + // The course the create route would open for this window. + let course: { startsOn: Date; endsOn: Date | null } | null = null; + if (parsed.data.startsOn || parsed.data.endsOn) { + const endKey = parsed.data.endsOn + ? dayKeyOfDate(parsed.data.endsOn) + : null; + course = { + startsOn: + parsed.data.startsOn ?? + dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), + endsOn: parsed.data.endsOn ?? null, + }; + if (validateCourses([course], todayKey, false)) { + report.fail(row.line, "end_before_start", "endsOn"); + continue; + } + } + const key = identity(row.name, row.dose); + if (seen.has(key)) { + report.duplicate(row.line); + continue; + } + seen.add(key); + report.valid += 1; + report.inserted += 1; + if (dryRun) { + report.addPreview({ + line: row.line, + name: row.name, + dose: row.dose, + times: row.times.join(" "), + asNeeded: row.asNeeded, + startsOn: row.startsOn, + endsOn: row.endsOn, + }); + continue; + } + + const data = parsed.data; + // Field by field, with the create route's invariants for the shapes an + // import produces: a daily schedule (FREQ=DAILY, times dual-written), an + // opening course for a dated window, no schedule for as-needed. + const medication = await prisma.medication.create({ + data: { + userId, + name: data.name, + dose: data.dose, + asNeeded: data.asNeeded ?? false, + notificationsEnabled: data.notificationsEnabled ?? false, + ...(course + ? { + startsOn: course.startsOn, + endsOn: course.endsOn, + courses: { + create: { + userId, + startsOn: course.startsOn, + endsOn: course.endsOn, + }, + }, + } + : {}), + schedules: { + create: (data.schedules ?? []).map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: null, + dose: null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: [], + intervalWeeks: 1, + }), + timesOfDay: + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + rrule: "FREQ=DAILY", + })), + }, + }, + select: { id: true }, + }); + await setMedicationCategory(medication.id, undefined); + } + + if (!dryRun && report.inserted > 0) { + invalidateUserMedications(userId, { evict: true }); + } + return report.toResult(); +} diff --git a/src/lib/import/record-import-report.ts b/src/lib/import/record-import-report.ts new file mode 100644 index 000000000..af797c690 --- /dev/null +++ b/src/lib/import/record-import-report.ts @@ -0,0 +1,75 @@ +/** + * The per-import report both record importers fill (#1215): counts, the row + * errors in file order and a short preview, each bounded so a file of twenty + * thousand broken rows answers with a report of a fixed size. + */ +import type { + RecordImportResult, + RecordImportRowReason, +} from "@/lib/validations/record-import"; + +export const MAX_REPORTED_ERRORS = 500; +export const MAX_PREVIEW_ROWS = 20; + +export type PreviewRow = Record; + +export class RecordImportReport { + rows = 0; + valid = 0; + inserted = 0; + duplicates = 0; + failed = 0; + private errors: RecordImportResult["errors"] = []; + private truncated = false; + private preview: PreviewRow[] = []; + + constructor( + readonly kind: RecordImportResult["kind"], + readonly dryRun: boolean, + ) {} + + private note(line: number, reason: RecordImportRowReason, field?: string) { + if (this.errors.length < MAX_REPORTED_ERRORS) { + this.errors.push(field ? { line, reason, field } : { line, reason }); + } else { + this.truncated = true; + } + } + + fail(line: number, reason: RecordImportRowReason, field?: string) { + this.failed += 1; + this.note(line, reason, field); + } + + duplicate(line: number) { + this.duplicates += 1; + this.note(line, "duplicate"); + } + + addPreview(row: PreviewRow) { + if (this.dryRun && this.preview.length < MAX_PREVIEW_ROWS) { + this.preview.push(row); + } + } + + toResult( + extra: Pick = {}, + ): RecordImportResult { + // Errors arrive per chunk; a chunk's duplicates are found after its + // refusals, so put the list back in file order. + const errors = [...this.errors].sort((a, b) => a.line - b.line); + return { + kind: this.kind, + dryRun: this.dryRun, + rows: this.rows, + valid: this.valid, + inserted: this.inserted, + duplicates: this.duplicates, + failed: this.failed, + errors, + errorsTruncated: this.truncated, + ...extra, + preview: this.preview, + }; + } +} diff --git a/src/lib/import/record-import-route.ts b/src/lib/import/record-import-route.ts new file mode 100644 index 000000000..ded29bc6e --- /dev/null +++ b/src/lib/import/record-import-route.ts @@ -0,0 +1,150 @@ +/** + * The request side the two CSV record imports share (#1215): owner-only auth, + * the module gate, the rate buckets, the query parse, the content type and + * the streamed body with its byte cap. Each route supplies its schema and its + * importer; what a row becomes lives in `labs-csv.ts` / `medications-csv.ts`. + * + * Buckets: a dry run spends `import-preview:` (30 an hour), because the + * mapping screen re-checks the file whenever a column choice changes; the + * real import spends the shared `import:` bucket (5 an hour) every other + * importer uses. + */ +import type { NextRequest } from "next/server"; +import type { z } from "zod/v4"; + +import { requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { CsvTooLargeError } from "@/lib/import/csv-stream"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +export const RECORD_IMPORT_MAX_BYTES = 16 * 1024 * 1024; +const PREVIEW_PER_HOUR = 30; +const IMPORT_PER_HOUR = 5; +const HOUR_MS = 60 * 60 * 1000; + +export async function handleRecordImport< + S extends z.ZodType<{ dryRun: boolean }>, +>( + request: NextRequest, + opts: { + kind: RecordImportResult["kind"]; + module: ModuleKey; + schema: S; + run: (args: { + userId: string; + tz: string; + stream: ReadableStream; + maxBytes: number; + query: z.infer; + }) => Promise; + }, +): Promise { + const { user } = await requireAuth(); + const action = `import.${opts.kind}.csv`; + annotate({ action: { name: action } }); + + const gate = await requireModuleEnabled(user.id, opts.module); + if (!gate.enabled) return gate.response; + + const parsed = opts.schema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const query = parsed.data; + + const rl = query.dryRun + ? await checkRateLimit( + `import-preview:${user.id}`, + PREVIEW_PER_HOUR, + HOUR_MS, + ) + : await checkRateLimit(`import:${user.id}`, IMPORT_PER_HOUR, HOUR_MS); + if (!rl.allowed) { + return apiError( + query.dryRun + ? `Maximum ${PREVIEW_PER_HOUR} checks per hour` + : `Maximum ${IMPORT_PER_HOUR} imports per hour`, + 429, + { errorCode: "import.rate_limited" }, + ); + } + + const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); + if ( + !contentType.includes("text/csv") && + !contentType.includes("text/plain") + ) { + return apiError("Content-Type must be text/csv", 415, { + errorCode: "import.csv.content_type", + }); + } + const declared = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > RECORD_IMPORT_MAX_BYTES) { + return apiError("CSV exceeds the 16 MB limit", 413, { + errorCode: "import.csv.too_large", + }); + } + if (!request.body) { + return apiError("The CSV file is empty", 422, { + errorCode: "import.csv.empty", + }); + } + + const tz = await resolveUserTimezone(user.id); + let result: RecordImportResult; + try { + result = await opts.run({ + userId: user.id, + tz, + stream: request.body, + maxBytes: RECORD_IMPORT_MAX_BYTES, + query, + }); + } catch (err) { + if (err instanceof CsvTooLargeError) { + return apiError("CSV exceeds the 16 MB limit", 413, { + errorCode: "import.csv.too_large", + }); + } + throw err; + } + if (result.rows === 0) { + return apiError("The CSV file has no data rows", 422, { + errorCode: "import.csv.empty", + }); + } + + if (!query.dryRun) { + await auditLog(action, { + userId: user.id, + ipAddress: getClientIp(request), + details: { + rows: result.rows, + inserted: result.inserted, + duplicates: result.duplicates, + failed: result.failed, + }, + }); + } + annotate({ + meta: { + import_dry_run: query.dryRun, + import_rows: result.rows, + import_inserted: result.inserted, + import_duplicates: result.duplicates, + import_failed: result.failed, + }, + }); + return apiSuccess(result); +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d43ec2437..b91330fb2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes"; */ export const ERROR_CODE_CATALOGUE: Readonly> = { + activity: ["activity.calendar.future_date", "activity.calendar.too_old"], ai: [ "ai.budget.exceeded", "ai.provider.none", @@ -225,7 +226,14 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "illness.episode.parent-not-found", "illness.insights.invalid", ], - import: ["import.apple_health.busy", "import.write_failed"], + import: [ + "import.apple_health.busy", + "import.csv.content_type", + "import.csv.empty", + "import.csv.too_large", + "import.rate_limited", + "import.write_failed", + ], ingest: ["ingest.rate_limited"], insights: [ "insights.generate.budgetExceeded", diff --git a/src/lib/openapi/routes/activity.ts b/src/lib/openapi/routes/activity.ts new file mode 100644 index 000000000..52fa5072e --- /dev/null +++ b/src/lib/openapi/routes/activity.ts @@ -0,0 +1,102 @@ +/** + * OpenAPI route table — the activity calendar and activity goals (D1036). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas + * are the runtime ones from `@/lib/validations/activity-*`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + activityCalendarQuerySchema, + activityCalendarResponseSchema, + activityGoalsResponseSchema, +} from "@/lib/validations/activity-calendar"; +import { activityGoalsSchema } from "@/lib/validations/activity-goals"; + +import { + dataEnvelope, + MODULE_DISABLED_DESCRIPTION, + recordRefusal, + recordWriteRateLimitResponse, + stdResponses, +} from "./shared"; + +const activityGoalsPatch = activityGoalsSchema.meta({ + id: "ActivityGoalsPatch", + description: + "The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused.", +}); + +export const activityPaths: NonNullable = { + "/api/activity/calendar": { + get: { + tags: ["Measurements"], + summary: "Activity calendar against the person's own normal", + description: + "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module.", + requestParams: { query: activityCalendarQuerySchema }, + responses: { + "200": { + description: "The calendar.", + content: { + "application/json": { + schema: dataEnvelope( + activityCalendarResponseSchema, + "ActivityCalendarEnvelope", + ), + }, + }, + }, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...stdResponses, + }, + }, + }, + "/api/activity/goals": { + get: { + tags: ["Measurements"], + summary: "Activity goals", + description: + "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar.", + responses: { + "200": { + description: "The goals.", + content: { + "application/json": { + schema: dataEnvelope( + activityGoalsResponseSchema, + "ActivityGoalsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Measurements"], + summary: "Set activity goals", + description: + "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals.", + requestBody: { + required: true, + content: { "application/json": { schema: activityGoalsPatch } }, + }, + responses: { + "200": { + description: "The goals after the change.", + content: { + "application/json": { + schema: dataEnvelope( + activityGoalsResponseSchema, + "ActivityGoalsPatchEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...recordWriteRateLimitResponse, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 2611958c4..6fee3fa7f 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -23,6 +23,41 @@ import { stdResponses, } from "./shared"; import { isoDateTime } from "@/lib/validations/datetime"; +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, + recordImportResultSchema, +} from "@/lib/validations/record-import"; + +const RECORD_IMPORT_COMMON = + "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off."; + +function recordImportResponses(id: string) { + return { + "200": { + description: "Per-file and per-row outcome.", + content: { + "application/json": { + schema: dataEnvelope(recordImportResultSchema, id), + }, + }, + }, + ...stdResponses, + "403": { + description: + "`module.disabled`: the module the import writes to is switched off.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "`import.csv.too_large`: the file is larger than 16 MB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`import.csv.content_type`: the body is not `text/csv`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }; +} const csvImportRowResult = z .object({ @@ -351,6 +386,48 @@ export const importPaths: NonNullable = { }, }, }, + "/api/import/labs-csv": { + post: { + tags: ["Import"], + summary: "Import lab results from CSV", + description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`, + requestParams: { query: labsCsvQuerySchema }, + requestBody: { + required: true, + content: { + "text/csv": { + schema: { + type: "string", + example: + "Analyte;Value;Unit;Date;Range\nHbA1c;5,4;%;01.03.2025;4,0-5,6\nUrine protein;negative;;01.03.2025;", + }, + }, + }, + }, + responses: recordImportResponses("LabsCsvImportEnvelope"), + }, + }, + "/api/import/medications-csv": { + post: { + tags: ["Import"], + summary: "Import medications from CSV", + description: `${RECORD_IMPORT_COMMON} Maps name, dose (as written), daily intake times, an as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless \`reminders=1\`. A medication the record holds under the same name and dose is a duplicate. At most 500 data rows.`, + requestParams: { query: medicationsCsvQuerySchema }, + requestBody: { + required: true, + content: { + "text/csv": { + schema: { + type: "string", + example: + "name,dose,times,as needed,start,end\nRamipril,5 mg,08:00,,2025-01-01,\nIbuprofen,400 mg,,yes,,", + }, + }, + }, + }, + responses: recordImportResponses("MedicationsCsvImportEnvelope"), + }, + }, "/api/import": { post: { tags: ["Import"], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 96c0d4375..e72f9655a 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -27,6 +27,8 @@ import { recordEpochParameter, recordScopeParameter, } from "./account-sharing"; +import { activityPaths } from "./activity"; +import { provenancePaths } from "./provenance"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { aiUsagePaths } from "./ai-usage"; import { dawarichPaths } from "./dawarich"; @@ -105,6 +107,8 @@ export const openApiPaths: NonNullable = { ...syncPaths, ...measurementPaths, ...workoutPaths, + ...activityPaths, + ...provenancePaths, ...devicePaths, ...medicationPaths, ...profilePaths, diff --git a/src/lib/openapi/routes/provenance.ts b/src/lib/openapi/routes/provenance.ts new file mode 100644 index 000000000..60f8c64ff --- /dev/null +++ b/src/lib/openapi/routes/provenance.ts @@ -0,0 +1,67 @@ +/** + * OpenAPI route table — source provenance (#1204, #1108, #576). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas + * are the runtime ones from `@/lib/validations/source-provenance`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + dayProvenanceQuerySchema, + dayProvenanceResponseSchema, + sourceComparisonQuerySchema, + sourceComparisonResponseSchema, +} from "@/lib/validations/source-provenance"; + +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; + +export const provenancePaths: NonNullable = { + "/api/measurements/provenance": { + get: { + tags: ["Measurements"], + summary: "Which source a metric's day value comes from", + description: + "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and no pick: every source counts there. Read-only.", + requestParams: { query: dayProvenanceQuerySchema }, + responses: { + "200": { + description: "The day's sources and the pick.", + content: { + "application/json": { + schema: dataEnvelope( + dayProvenanceResponseSchema, + "DayProvenanceEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/measurements/provenance/compare": { + get: { + tags: ["Measurements"], + summary: "Two sources of one metric side by side", + description: + "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. Without `a` / `b` the two sources covering the most days are compared. Read-only.", + requestParams: { query: sourceComparisonQuerySchema }, + responses: { + "200": { + description: "The comparison.", + content: { + "application/json": { + schema: dataEnvelope( + sourceComparisonResponseSchema, + "SourceComparisonEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/provenance/__tests__/ranked-types.test.ts b/src/lib/provenance/__tests__/ranked-types.test.ts new file mode 100644 index 000000000..5eca4497c --- /dev/null +++ b/src/lib/provenance/__tests__/ranked-types.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { RANKED_TYPES } from "@/lib/analytics/source-rank-sql"; + +import { PROVENANCE_TYPES, isProvenanceType } from "../ranked-types"; + +describe("the provenance metric list", () => { + it("is exactly the set of types the ladder ranks", () => { + expect([...PROVENANCE_TYPES].sort()).toEqual([...RANKED_TYPES].sort()); + }); + it("answers membership", () => { + expect(isProvenanceType("ACTIVITY_STEPS")).toBe(true); + expect(isProvenanceType("BLOOD_GLUCOSE")).toBe(false); + }); +}); diff --git a/src/lib/provenance/__tests__/source-provenance.test.ts b/src/lib/provenance/__tests__/source-provenance.test.ts new file mode 100644 index 000000000..17cc5f773 --- /dev/null +++ b/src/lib/provenance/__tests__/source-provenance.test.ts @@ -0,0 +1,228 @@ +/** + * The provenance explanation says what the canonical readers do. + * + * 1. The ladder the explanation reads (`ladderFor` + `ladderRank`) gives + * every (type, source) the rank the SQL CASE from `buildSourceRankCase` + * gives it, for the default ladders and for a reordered one. The CASE is + * evaluated from its own text here, so a change to either side fails. + * 2. The provenance query orders a day's sources by exactly the expression + * `canonicalMeasurementsCte` picks with: the same rank CASE, then the + * source name. + * 3. The pick explanation and the comparison are right on hand-made rows. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queryRawUnsafe: vi.fn(), + priority: null as unknown, +})); +vi.mock("@/lib/db", () => ({ + prisma: { $queryRawUnsafe: mocks.queryRawUnsafe }, +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => mocks.priority), +})); + +import { + RANKED_TYPES, + buildSourceRankCase, + canonicalMeasurementsCte, +} from "@/lib/analytics/source-rank-sql"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import type { MeasurementType } from "@/generated/prisma/client"; + +import { + OFF_LADDER_RANK, + buildComparison, + explainPick, + ladderFor, + ladderRank, + readDayProvenance, + type SourceRow, +} from "../source-provenance"; + +/** Evaluate the CASE text `buildSourceRankCase` emits for one (type, source). */ +function evaluateCase(sql: string, type: string, source: string): number { + const branch = new RegExp( + `WHEN "type" = '${type}' THEN \\(CASE "source" (.*?) ELSE 90 END\\)`, + ).exec(sql); + if (!branch) return 90; + const when = new RegExp(`WHEN '${source}' THEN (\\d+)`).exec(branch[1]); + return when ? Number(when[1]) : 90; +} + +const REORDERED = { + metricPriority: { + steps: ["OURA", "WHOOP", "APPLE_HEALTH"], + sleep: ["WHOOP", "OURA"], + hrvRmssd: ["OURA"], + }, +}; + +describe("the explanation ranks exactly like the SQL", () => { + it.each([ + ["default ladders", null], + ["a reordered ladder", REORDERED], + ])("%s", (_label, priority) => { + const sql = buildSourceRankCase(priority, '"type"', '"source"'); + let compared = 0; + for (const type of RANKED_TYPES) { + const { ladder } = ladderFor(priority, type); + for (const source of measurementSourceEnum.options) { + expect(ladderRank(ladder, source), `${type}/${source}`).toBe( + evaluateCase(sql, type, source), + ); + compared += 1; + } + } + // A matcher that found no branch would agree with everything. + expect(compared).toBeGreaterThan(100); + expect(sql).toContain(`WHEN "type" = 'ACTIVITY_STEPS'`); + }); + + it("a type without a ladder is not ranked", () => { + expect(ladderFor(null, "BLOOD_GLUCOSE" as MeasurementType).ranked).toBe( + false, + ); + }); + + it("a reordered ladder moves the rank", () => { + const { ladder } = ladderFor(REORDERED, "ACTIVITY_STEPS"); + expect(ladderRank(ladder, "OURA")).toBe(0); + expect(ladderRank(ladder, "WHOOP")).toBe(1); + expect(ladderRank(ladder, "NOT_A_SOURCE")).toBe(OFF_LADDER_RANK); + }); +}); + +describe("the provenance query orders like the canonical pick", () => { + beforeEach(() => { + mocks.queryRawUnsafe.mockReset(); + mocks.queryRawUnsafe.mockResolvedValue([]); + }); + + it.each([null, REORDERED])("with priority %j", async (priority) => { + mocks.priority = priority; + await readDayProvenance({ + userId: "u1", + type: "SLEEP_DURATION", + date: "2026-10-01", + }); + const [sql, userId, from, to] = mocks.queryRawUnsafe.mock.calls[0]; + const rank = buildSourceRankCase(priority, '"type"', '"source"'); + const squash = (s: string) => s.replace(/\s+/g, " "); + expect(squash(sql)).toContain( + squash( + `ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`, + ), + ); + expect(squash(canonicalMeasurementsCte(rank))).toContain( + squash( + `ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`, + ), + ); + // Values are bound, never spliced. + expect(userId).toBe("u1"); + expect((from as Date).toISOString()).toBe("2026-10-01T00:00:00.000Z"); + expect((to as Date).toISOString()).toBe("2026-10-02T00:00:00.000Z"); + expect(sql).not.toContain("u1"); + }); +}); + +function row(source: string, rank: number, value: number): SourceRow { + return { + source, + rank, + readings: 1, + value, + min: value, + max: value, + firstAt: "2026-10-01T06:00:00.000Z", + lastAt: "2026-10-01T06:00:00.000Z", + unit: "min", + devices: ["unknown"], + }; +} + +describe("explainPick", () => { + it("names the highest on the ladder and why the others lost", () => { + const out = explainPick(true, [ + row("WHOOP", 0, 420), + row("OURA", 2, 400), + row("FITBIT", 90, 380), + ]); + expect(out.picked).toBe("WHOOP"); + expect(out.pickReason).toBe("highest_on_ladder"); + expect(out.passes).toEqual([null, "lower_on_ladder", "not_on_ladder"]); + }); + + it("calls a pick among off-ladder sources a name tiebreak", () => { + const out = explainPick(true, [row("FITBIT", 90, 1), row("OURA", 90, 2)]); + expect(out.pickReason).toBe("name_tiebreak"); + expect(out.passes).toEqual([null, "name_tiebreak"]); + }); + + it("one source is simply the only one; an unranked type picks nothing", () => { + expect(explainPick(true, [row("OURA", 1, 5)]).pickReason).toBe( + "only_source", + ); + const unranked = explainPick(false, [row("A", 90, 1), row("B", 90, 2)]); + expect(unranked.picked).toBeNull(); + expect(unranked.pickReason).toBe("not_ranked"); + expect(explainPick(true, []).picked).toBeNull(); + }); +}); + +describe("buildComparison", () => { + const rows = [ + { ...row("OURA", 1, 400), day: "2026-10-01" }, + { ...row("WHOOP", 0, 430), day: "2026-10-01" }, + { ...row("OURA", 1, 410), day: "2026-10-02" }, + { ...row("OURA", 1, 390), day: "2026-10-03" }, + { ...row("WHOOP", 0, 450), day: "2026-10-03" }, + ].sort((x, y) => x.day.localeCompare(y.day) || x.rank - y.rank); + + it("lines the two up per day and compares only days both cover", () => { + const c = buildComparison({ + type: "SLEEP_DURATION", + from: "2026-10-01", + to: "2026-10-03", + ranked: true, + ladder: ["WHOOP", "APPLE_HEALTH", "OURA"], + rows, + a: "OURA", + b: "WHOOP", + }); + expect(c.days).toEqual([ + { date: "2026-10-01", a: 400, b: 430, picked: "WHOOP" }, + { date: "2026-10-02", a: 410, b: null, picked: "OURA" }, + { date: "2026-10-03", a: 390, b: 450, picked: "WHOOP" }, + ]); + expect(c.summary).toMatchObject({ + daysA: 3, + daysB: 2, + daysBoth: 2, + meanA: 395, + meanB: 440, + meanDifference: 45, + meanAbsoluteDifference: 45, + pickedA: 1, + pickedB: 2, + }); + }); + + it("defaults to the two sources that cover the most days", () => { + const c = buildComparison({ + type: "SLEEP_DURATION", + from: "2026-10-01", + to: "2026-10-03", + ranked: true, + ladder: [], + rows, + }); + expect([c.a, c.b]).toEqual(["OURA", "WHOOP"]); + expect(c.sources).toEqual([ + { source: "OURA", days: 3 }, + { source: "WHOOP", days: 2 }, + ]); + }); +}); diff --git a/src/lib/provenance/ranked-types.ts b/src/lib/provenance/ranked-types.ts new file mode 100644 index 000000000..cf84a298f --- /dev/null +++ b/src/lib/provenance/ranked-types.ts @@ -0,0 +1,35 @@ +/** + * The measurement types that carry a source-priority ladder, as a plain list + * the browser can import (the server's `RANKED_TYPES` lives beside SQL + * builders and the schema library). `ranked-types.test.ts` holds the two + * lists equal, so the metric picker on the provenance page offers exactly + * the types the ladder decides for. + */ +export const PROVENANCE_TYPES = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "HRV_RMSSD", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "WEIGHT", + "BODY_FAT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "VO2_MAX", + "RECOVERY_SCORE", + "STRESS_SCORE", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", +] as const; + +export type ProvenanceType = (typeof PROVENANCE_TYPES)[number]; + +export function isProvenanceType(type: string): type is ProvenanceType { + return (PROVENANCE_TYPES as readonly string[]).includes(type); +} diff --git a/src/lib/provenance/source-provenance.ts b/src/lib/provenance/source-provenance.ts new file mode 100644 index 000000000..ce20e4ea8 --- /dev/null +++ b/src/lib/provenance/source-provenance.ts @@ -0,0 +1,415 @@ +/** + * Source provenance (#1204, #1108, #576): for one metric, which sources + * delivered values on a day, which one the source-priority ladder picked and + * why, and two sources side by side over a range. + * + * Read-only and computed by the SAME SQL the canonical readers use. The pick + * is made by `ORDER BY (rank), "source"::text` over one UTC day of one type, + * with the rank CASE from `buildSourceRankCase`: exactly the ordering inside + * `canonicalMeasurementsCte`'s `DISTINCT ON`, which the charts, the rollups + * and the summaries read through. The explanation is derived from the rank + * that SQL returned, not recomputed beside it, so the page cannot say one + * thing while the charts do another. `source-provenance.test.ts` holds the + * TypeScript ladder reading to the CASE the SQL runs. + * + * Two facts the page states plainly because they follow from that rule: + * - the pick is per UTC day, the unit every ladder read works in; + * - a type without a ladder (glucose, body water, ...) is not collapsed at + * all: every source's readings count, so no source is "picked". + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + RANKED_TYPES, + buildSourceRankCase, +} from "@/lib/analytics/source-rank-sql"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { shiftDateKey } from "@/lib/tz/format"; +import { + getSourceLadder, + parseSourcePriority, +} from "@/lib/validations/source-priority"; + +/** The rank the CASE gives a source that is not on the type's ladder. */ +export const OFF_LADDER_RANK = 90; + +const ENUM_RE = /^[A-Z0-9_]+$/; + +/** How one day's value of a source is formed: a day total, or a mean. */ +export function dayAggregation(type: MeasurementType): "sum" | "mean" { + return type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type) + ? "sum" + : "mean"; +} + +export interface LadderReading { + /** False for a type without a ladder: nothing is collapsed, nothing picked. */ + ranked: boolean; + metricKey: string | null; + /** The ladder as the person's settings resolve it, highest first. */ + ladder: string[]; +} + +/** The ladder a type is ranked by, read the way `buildSourceRankCase` reads it. */ +export function ladderFor( + priorityJson: unknown, + type: MeasurementType, +): LadderReading { + const metricKey = metricKeyForType(type); + if (!RANKED_TYPES.includes(type) || !metricKey) { + return { ranked: false, metricKey: metricKey ?? null, ladder: [] }; + } + const ladder = [ + ...getSourceLadder(parseSourcePriority(priorityJson), metricKey), + ]; + return { ranked: ladder.length > 0, metricKey, ladder }; +} + +/** The rank `buildSourceRankCase` assigns, computed from the ladder. */ +export function ladderRank(ladder: readonly string[], source: string): number { + const i = ladder.indexOf(source); + return i === -1 ? OFF_LADDER_RANK : i; +} + +export type PickReason = + /** The day has one source; there was nothing to choose. */ + | "only_source" + /** Picked because it stands highest on the ladder among the day's sources. */ + | "highest_on_ladder" + /** None of the day's sources is on the ladder; the name decided. */ + | "name_tiebreak" + /** No ladder for this type: every source counts. */ + | "not_ranked"; + +export type PassReason = + /** Lower on the ladder than the picked source. */ + | "lower_on_ladder" + /** Not on the ladder at all, below every source that is. */ + | "not_on_ladder" + /** Same rank as the picked one (both off the ladder); the name decided. */ + | "name_tiebreak"; + +export interface SourceRow { + source: string; + /** The rank the SQL CASE returned; `OFF_LADDER_RANK` when not on the ladder. */ + rank: number; + readings: number; + /** The day's value from this source: total or mean (see `aggregation`). */ + value: number | null; + min: number | null; + max: number | null; + firstAt: string; + lastAt: string; + unit: string | null; + /** Device kinds the readings carry (watch, ring, ...); `unknown` when untagged. */ + devices: string[]; +} + +export interface DayProvenance { + type: MeasurementType; + /** The UTC day the pick is made for. */ + date: string; + aggregation: "sum" | "mean"; + ranked: boolean; + metricKey: string | null; + ladder: string[]; + /** The source the canonical readers use for this day; null when not ranked or empty. */ + picked: string | null; + pickReason: PickReason | null; + sources: Array; +} + +interface RawSourceRow { + source: string; + rank: number; + readings: number; + total: number | null; + asleep: number | null; + mean: number | null; + min: number | null; + max: number | null; + first_at: Date; + last_at: Date; + unit: string | null; + devices: string[] | null; +} + +/** UTC midnight to UTC midnight: the day the ladder pick is made for. */ +function utcDayBounds(date: string): { from: Date; to: Date } { + return { + from: dayKeyAsUtcMidnight(date), + to: dayKeyAsUtcMidnight(shiftDateKey(date, 1)), + }; +} + +/** + * Per (UTC day, source) aggregates for one type over `[from, to)`, ordered the + * way the canonical pick orders them. The rank CASE and the tiebreak are + * spliced from the same builder and in the same order as + * `canonicalMeasurementsCte`; every request value is a bound parameter. + */ +async function readSourceDays( + userId: string, + priorityJson: unknown, + type: MeasurementType, + from: Date, + to: Date, +): Promise> { + if (!ENUM_RE.test(type)) { + throw new Error(`unsafe enum literal for SQL splice: ${type}`); + } + const rank = buildSourceRankCase(priorityJson, '"type"', '"source"'); + return prisma.$queryRawUnsafe>( + ` + SELECT + to_char(date_trunc('day', "measured_at"), 'YYYY-MM-DD') AS day, + "source"::text AS source, + (${rank})::int AS rank, + COUNT(*)::int AS readings, + SUM("value")::double precision AS total, + SUM("value") FILTER ( + WHERE "sleep_stage" IS NULL + OR "sleep_stage" NOT IN ('AWAKE'::"sleep_stage", 'IN_BED'::"sleep_stage") + )::double precision AS asleep, + (SUM("value") / NULLIF(COUNT(*), 0))::double precision AS mean, + MIN("value")::double precision AS min, + MAX("value")::double precision AS max, + MIN("measured_at") AS first_at, + MAX("measured_at") AS last_at, + MIN("unit") AS unit, + ARRAY_AGG(DISTINCT COALESCE("device_type", 'unknown')) AS devices + FROM measurements + WHERE "user_id" = $1 + AND "type" = '${type}'::"measurement_type" + AND "deleted_at" IS NULL + AND "measured_at" >= $2 + AND "measured_at" < $3 + GROUP BY date_trunc('day', "measured_at"), "type", "source" + ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text + `, + userId, + from, + to, + ); +} + +function dayValue(type: MeasurementType, row: RawSourceRow): number | null { + if (type === "SLEEP_DURATION") return row.asleep; + return dayAggregation(type) === "sum" ? row.total : row.mean; +} + +function toSourceRow(type: MeasurementType, row: RawSourceRow): SourceRow { + return { + source: row.source, + rank: row.rank, + readings: row.readings, + value: dayValue(type, row), + min: row.min, + max: row.max, + firstAt: row.first_at.toISOString(), + lastAt: row.last_at.toISOString(), + unit: row.unit, + devices: [...(row.devices ?? [])].sort(), + }; +} + +/** + * Explain a day's pick from rows already in pick order (rank, then name). + * Pure, so the reasoning is tested without a database. + */ +export function explainPick( + ranked: boolean, + rows: readonly SourceRow[], +): { + picked: string | null; + pickReason: PickReason | null; + passes: Array; +} { + if (rows.length === 0) { + return { picked: null, pickReason: null, passes: [] }; + } + if (!ranked) { + return { + picked: null, + pickReason: "not_ranked", + passes: rows.map(() => null), + }; + } + const winner = rows[0]; + const pickReason: PickReason = + rows.length === 1 + ? "only_source" + : winner.rank === OFF_LADDER_RANK + ? "name_tiebreak" + : "highest_on_ladder"; + const passes = rows.map((row, i): PassReason | null => { + if (i === 0) return null; + if (row.rank === winner.rank) return "name_tiebreak"; + return row.rank === OFF_LADDER_RANK ? "not_on_ladder" : "lower_on_ladder"; + }); + return { picked: winner.source, pickReason, passes }; +} + +export async function readDayProvenance(args: { + userId: string; + type: MeasurementType; + date: string; +}): Promise { + const { userId, type, date } = args; + const priorityJson = await loadUserSourcePriority(userId); + const ladder = ladderFor(priorityJson, type); + const { from, to } = utcDayBounds(date); + const raw = await readSourceDays(userId, priorityJson, type, from, to); + const rows = raw.map((r) => toSourceRow(type, r)); + const { picked, pickReason, passes } = explainPick(ladder.ranked, rows); + return { + type, + date, + aggregation: dayAggregation(type), + ranked: ladder.ranked, + metricKey: ladder.metricKey, + ladder: ladder.ladder, + picked, + pickReason, + sources: rows.map((row, i) => ({ ...row, pass: passes[i] ?? null })), + }; +} + +export interface CompareDay { + date: string; + a: number | null; + b: number | null; + /** The source the canonical readers use that day (among all its sources). */ + picked: string | null; +} + +export interface SourceComparison { + type: MeasurementType; + from: string; + to: string; + aggregation: "sum" | "mean"; + ranked: boolean; + ladder: string[]; + unit: string | null; + /** Every source with readings in the range, by days covered. */ + sources: Array<{ source: string; days: number }>; + a: string | null; + b: string | null; + days: CompareDay[]; + summary: { + daysA: number; + daysB: number; + daysBoth: number; + /** Means over the days both sources cover, so the two are comparable. */ + meanA: number | null; + meanB: number | null; + /** Mean of (b − a) over the days both cover. */ + meanDifference: number | null; + meanAbsoluteDifference: number | null; + /** Days each source was the one the ladder picked. */ + pickedA: number; + pickedB: number; + }; +} + +/** Pure assembly of a comparison from per-(day, source) rows in pick order. */ +export function buildComparison(args: { + type: MeasurementType; + from: string; + to: string; + ranked: boolean; + ladder: string[]; + rows: ReadonlyArray; + a?: string | null; + b?: string | null; +}): SourceComparison { + const { type, from, to, ranked, ladder, rows } = args; + const byDay = new Map>(); + const coverage = new Map(); + for (const row of rows) { + const list = byDay.get(row.day) ?? []; + list.push(row); + byDay.set(row.day, list); + coverage.set(row.source, (coverage.get(row.source) ?? 0) + 1); + } + const sources = [...coverage] + .map(([source, days]) => ({ source, days })) + .sort((x, y) => y.days - x.days || x.source.localeCompare(y.source)); + const a = args.a ?? sources[0]?.source ?? null; + const b = args.b ?? sources.find((s) => s.source !== a)?.source ?? null; + + const days: CompareDay[] = [...byDay.keys()].sort().map((date) => { + const list = byDay.get(date) ?? []; + const pick = explainPick(ranked, list); + return { + date, + a: list.find((r) => r.source === a)?.value ?? null, + b: list.find((r) => r.source === b)?.value ?? null, + picked: pick.picked, + }; + }); + + const both = days.filter((d) => d.a !== null && d.b !== null); + const mean = (xs: number[]) => + xs.length === 0 ? null : xs.reduce((s, x) => s + x, 0) / xs.length; + return { + type, + from, + to, + aggregation: dayAggregation(type), + ranked, + ladder, + unit: rows.find((r) => r.unit)?.unit ?? null, + sources, + a, + b, + days, + summary: { + daysA: days.filter((d) => d.a !== null).length, + daysB: days.filter((d) => d.b !== null).length, + daysBoth: both.length, + meanA: mean(both.map((d) => d.a as number)), + meanB: mean(both.map((d) => d.b as number)), + meanDifference: mean(both.map((d) => (d.b as number) - (d.a as number))), + meanAbsoluteDifference: mean( + both.map((d) => Math.abs((d.b as number) - (d.a as number))), + ), + pickedA: days.filter((d) => a !== null && d.picked === a).length, + pickedB: days.filter((d) => b !== null && d.picked === b).length, + }, + }; +} + +export async function readSourceComparison(args: { + userId: string; + type: MeasurementType; + /** Inclusive UTC days. */ + from: string; + to: string; + a?: string | null; + b?: string | null; +}): Promise { + const { userId, type, from, to } = args; + const priorityJson = await loadUserSourcePriority(userId); + const ladder = ladderFor(priorityJson, type); + const raw = await readSourceDays( + userId, + priorityJson, + type, + utcDayBounds(from).from, + utcDayBounds(to).to, + ); + return buildComparison({ + type, + from, + to, + ranked: ladder.ranked, + ladder: ladder.ladder, + rows: raw.map((r) => ({ ...toSourceRow(type, r), day: r.day })), + a: args.a, + b: args.b, + }); +} diff --git a/src/lib/query-keys/activity.ts b/src/lib/query-keys/activity.ts new file mode 100644 index 000000000..812841224 --- /dev/null +++ b/src/lib/query-keys/activity.ts @@ -0,0 +1,11 @@ +/** + * Query keys — the activity calendar and activity goals (D1036). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const activityKeys = { + /** Root of every activity-calendar cell; a goal save invalidates it. */ + activityCalendarAll: () => ["activity-calendar"] as const, + activityCalendar: (period: "week" | "month", date: string | null) => + ["activity-calendar", period, date ?? "today"] as const, + activityGoals: () => ["activity-goals"] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 7fc422cc6..9f43af097 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -47,6 +47,8 @@ import { searchKeys } from "./search"; import { sharingKeys } from "./sharing"; import { vaccinationKeys } from "./vaccinations"; import { workoutKeys } from "./workouts"; +import { activityKeys } from "./activity"; +import { provenanceKeys } from "./provenance"; export const queryKeys = { ...authKeys, @@ -60,6 +62,8 @@ export const queryKeys = { ...adminKeys, ...integrationKeys, ...workoutKeys, + ...activityKeys, + ...provenanceKeys, ...settingsKeys, ...cycleKeys, ...measurementReminderKeys, diff --git a/src/lib/query-keys/provenance.ts b/src/lib/query-keys/provenance.ts new file mode 100644 index 000000000..e6b0a0b57 --- /dev/null +++ b/src/lib/query-keys/provenance.ts @@ -0,0 +1,15 @@ +/** + * Query keys — the source-provenance reads (#1204). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const provenanceKeys = { + provenanceDay: (type: string, date: string) => + ["provenance", "day", type, date] as const, + provenanceCompare: (opts: { + type: string; + from: string; + to: string; + a?: string; + b?: string; + }) => ["provenance", "compare", opts] as const, +}; diff --git a/src/lib/validations/activity-calendar.ts b/src/lib/validations/activity-calendar.ts new file mode 100644 index 000000000..75ba90c65 --- /dev/null +++ b/src/lib/validations/activity-calendar.ts @@ -0,0 +1,182 @@ +/** + * Query of `GET /api/activity/calendar` (D1036). + */ +import { z } from "zod/v4"; + +export const activityCalendarQuerySchema = z.object({ + period: z + .enum(["week", "month"]) + .default("month") + .describe("Calendar span: one week (Monday to Sunday) or one month."), + date: z.iso + .date() + .optional() + .describe( + "Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused.", + ), +}); + +export type ActivityCalendarQuery = z.infer; + +const dateKey = z.iso.date(); + +const dayLevelEnum = z + .enum(["none", "unrated", "low", "below", "usual", "above", "high"]) + .describe( + "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.", + ); + +const baselineStatsSchema = z + .object({ + median: z.number(), + p10: z.number(), + p25: z.number(), + p75: z.number(), + p90: z.number(), + days: z + .number() + .int() + .describe("Days with a value the spread was computed from."), + }) + .nullable() + .meta({ id: "ActivityBaselineStats" }); + +const periodComparisonSchema = z + .object({ + value: z + .number() + .nullable() + .describe( + "The period so far: a total (steps, active minutes, workouts) or a mean (load).", + ), + daysWithData: z.number().int(), + usual: z + .number() + .nullable() + .describe( + "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data.", + ), + usualToDate: z + .number() + .nullable() + .describe( + "The usual figure for the days elapsed so far; equals `usual` once the period is over.", + ), + referencePeriods: z.number().int(), + }) + .meta({ id: "ActivityPeriodComparison" }); + +const goalProgressSchema = z + .object({ + key: z.enum([ + "workoutsPerWeek", + "activeMinutesPerWeek", + "stepsPerWeek", + "stepsPerMonth", + ]), + target: z.number().int(), + value: z.number(), + met: z.boolean(), + }) + .meta({ id: "ActivityGoalProgress" }); + +const goalsSchema = z + .object({ + workoutsPerWeek: z.number().int().nullable(), + activeMinutesPerWeek: z.number().int().nullable(), + stepsPerWeek: z.number().int().nullable(), + stepsPerMonth: z.number().int().nullable(), + }) + .meta({ + id: "ActivityGoals", + description: + "The person's own goals; `null` = not set. There is no default goal.", + }); + +export const activityGoalsResponseSchema = goalsSchema; + +export const activityCalendarResponseSchema = z + .object({ + period: z.enum(["week", "month"]), + start: dateKey, + end: dateKey, + gridStart: dateKey.describe( + "First day drawn (the Monday on or before `start`).", + ), + gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."), + today: dateKey, + timezone: z.string().describe("The IANA zone the days are cut in."), + loadType: z + .enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"]) + .nullable() + .describe( + "Which training-load score `load` carries; null when the account has none.", + ), + days: z.array( + z.object({ + date: dateKey, + inPeriod: z.boolean(), + future: z.boolean(), + steps: z.number().nullable(), + activeMinutes: z + .number() + .nullable() + .describe( + "The larger of the day's exercise minutes and workout minutes (never their sum).", + ), + workoutCount: z.number().int(), + workoutsBySport: z.record(z.string(), z.number().int()), + load: z.number().nullable(), + level: z.object({ + steps: dayLevelEnum, + activeMinutes: dayLevelEnum, + load: dayLevelEnum, + }), + }), + ), + weeks: z.array( + z.object({ + start: dateKey, + end: dateKey, + steps: z.number().nullable(), + activeMinutes: z.number().nullable(), + workouts: z.number().int(), + goals: z + .array(goalProgressSchema) + .describe("The weekly goals for a week that has begun."), + }), + ), + baseline: z.object({ + steps: baselineStatsSchema, + activeMinutes: baselineStatsSchema, + load: baselineStatsSchema, + }), + summary: z.object({ + steps: periodComparisonSchema, + activeMinutes: periodComparisonSchema, + workouts: periodComparisonSchema, + load: periodComparisonSchema, + }), + sports: z.array( + z.object({ sportType: z.string(), count: z.number().int() }), + ), + goals: goalsSchema, + goalProgress: z.array(goalProgressSchema), + available: z.object({ + steps: z.boolean(), + activeMinutes: z.boolean(), + workouts: z.boolean(), + load: z.boolean(), + }), + previous: dateKey.describe("A day in the previous period."), + next: dateKey + .nullable() + .describe( + "A day in the next period, or null when that lies after today.", + ), + }) + .meta({ id: "ActivityCalendar" }); + +export type ActivityCalendarResponse = z.infer< + typeof activityCalendarResponseSchema +>; diff --git a/src/lib/validations/activity-goals.ts b/src/lib/validations/activity-goals.ts new file mode 100644 index 000000000..3b34ff80a --- /dev/null +++ b/src/lib/validations/activity-goals.ts @@ -0,0 +1,98 @@ +/** + * Activity goals a person sets for themselves (D1036). + * + * Stored in `User.activityGoalsJson`. Every goal is optional and a `null` + * clears it; an account that never set one stores nothing and reads as "no + * goals". The calendar reports progress against the goals that are set and + * says nothing about the ones that are not: there is no default goal and no + * population norm here, only the person's own number. + * + * The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the + * browser can read them without the schema. + */ +import { z } from "zod/v4"; + +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; + +export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey }; + +function goalField(key: ActivityGoalKey) { + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + return z.number().int().min(min).max(max).nullable().optional(); +} + +/** The stored shape and the PATCH body: every key optional, `null` clears. */ +export const activityGoalsSchema = z + .object({ + workoutsPerWeek: goalField("workoutsPerWeek").describe( + "Workouts per week (Monday to Sunday). `null` clears the goal.", + ), + activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe( + "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal.", + ), + stepsPerWeek: goalField("stepsPerWeek").describe( + "Steps per week. `null` clears the goal.", + ), + stepsPerMonth: goalField("stepsPerMonth").describe( + "Steps per calendar month. `null` clears the goal.", + ), + }) + .strict(); + +export type ActivityGoalsInput = z.infer; + +/** The resolved goals: every key present, `null` = not set. */ +export type ActivityGoals = Record; + +export const NO_ACTIVITY_GOALS: ActivityGoals = { + workoutsPerWeek: null, + activeMinutesPerWeek: null, + stepsPerWeek: null, + stepsPerMonth: null, +}; + +/** + * Read a stored value. Anything that does not parse (a hand-edited row, a + * shape from a future release) reads key by key: a valid goal survives, an + * invalid one reads as not set. Never throws. + */ +export function parseActivityGoals(raw: unknown): ActivityGoals { + const out: ActivityGoals = { ...NO_ACTIVITY_GOALS }; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return out; + } + const record = raw as Record; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = goalField(key).safeParse(record[key]); + if (parsed.success && typeof parsed.data === "number") { + out[key] = parsed.data; + } + } + return out; +} + +/** + * Apply a validated PATCH to the stored goals: a key the body names replaces + * the stored value (`null` clears it), a key it leaves out stays as it was. + * Built key by key, never by spreading the body. + */ +export function mergeActivityGoals( + current: ActivityGoals, + patch: ActivityGoalsInput, +): ActivityGoals { + const next: ActivityGoals = { ...current }; + for (const key of ACTIVITY_GOAL_KEYS) { + const value = patch[key]; + if (value !== undefined) next[key] = value; + } + return next; +} + +/** True when no goal is set, so the column can go back to null. */ +export function hasNoActivityGoals(goals: ActivityGoals): boolean { + return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null); +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 257f7c6b7..b08727ca5 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z documentsLayoutJson: z.unknown().optional(), moduleListLayoutJson: z.unknown().optional(), previewFeaturesJson: z.unknown().optional(), + activityGoalsJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(), diff --git a/src/lib/validations/record-import.ts b/src/lib/validations/record-import.ts new file mode 100644 index 000000000..c6c572d1f --- /dev/null +++ b/src/lib/validations/record-import.ts @@ -0,0 +1,241 @@ +/** + * Query and response schemas of the CSV record imports for lab results and + * medications (#1215). + * + * The body of both routes is the raw CSV file (`text/csv`), streamed. How to + * read it travels in the query: the column each field comes from (0-based + * index into the file's columns), the separator, the decimal mark, the date + * order and the zone a time without an offset is read in. The client works + * these out with the person on the mapping screen; the server never guesses + * a column. + */ +import { z } from "zod/v4"; + +import { MAX_COLUMNS } from "@/lib/import/csv-stream"; +import { isValidTimezone } from "@/lib/tz/format"; + +const column = z.coerce + .number() + .int() + .min(0) + .max(MAX_COLUMNS - 1) + .describe("0-based index of the file column this field is read from."); + +const formatFields = { + dryRun: z + .enum(["1", "0", "true", "false"]) + .default("0") + .transform((v) => v === "1" || v === "true") + .describe( + "`1` validates every row and reports what would happen, writing nothing.", + ), + header: z + .enum(["1", "0"]) + .default("1") + .transform((v) => v === "1") + .describe("`1` when the first row holds column names and is not data."), + delimiter: z + .enum(["auto", "comma", "semicolon", "tab"]) + .default("auto") + .describe("Column separator; `auto` detects it from the first line."), + decimal: z + .enum(["dot", "comma"]) + .default("dot") + .describe( + "Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous.", + ), + dateOrder: z + .enum(["iso", "dmy", "mdy"]) + .default("iso") + .describe( + "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused.", + ), + timezone: z + .string() + .max(64) + .refine(isValidTimezone, { message: "Unknown time zone" }) + .optional() + .describe( + "IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date.", + ), +}; + +function distinctColumns( + value: Record, + keys: readonly string[], +): boolean { + const used = keys + .map((k) => value[k]) + .filter((v): v is number => typeof v === "number"); + return new Set(used).size === used.length; +} + +const LAB_COLUMN_KEYS = [ + "analyte", + "value", + "unit", + "date", + "time", + "referenceLow", + "referenceHigh", + "referenceRange", + "panel", + "note", +] as const; + +export const labsCsvQuerySchema = z + .object({ + ...formatFields, + analyte: column.describe("Column of the marker name (required)."), + value: column.describe( + "Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept as a qualitative result.", + ), + unit: column + .optional() + .describe("Column of the unit. Required for a numeric result."), + date: column.describe("Column of the date, or date and time (required)."), + time: column.optional().describe("Column of the time, when separate."), + referenceLow: column.optional(), + referenceHigh: column.optional(), + referenceRange: column + .optional() + .describe("Column of a printed range such as `4.0-5.6` or `< 200`."), + panel: column.optional(), + note: column.optional().describe("Column of a note; stored encrypted."), + }) + .refine((v) => distinctColumns(v, LAB_COLUMN_KEYS), { + message: "Each column can feed one field", + path: ["analyte"], + }); + +export type LabsCsvQuery = z.infer; + +const MEDICATION_COLUMN_KEYS = [ + "name", + "dose", + "times", + "asNeeded", + "startsOn", + "endsOn", +] as const; + +export const medicationsCsvQuerySchema = z + .object({ + ...formatFields, + name: column.describe("Column of the medication name (required)."), + dose: column.describe( + "Column of the dose as written, e.g. `10 mg` (required).", + ), + times: column + .optional() + .describe( + "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed.", + ), + asNeeded: column + .optional() + .describe("Column marking an as-needed medication (yes/no)."), + startsOn: column.optional().describe("Column of the start date."), + endsOn: column.optional().describe("Column of the end date."), + reminders: z + .enum(["1", "0"]) + .default("0") + .transform((v) => v === "1") + .describe( + "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own.", + ), + }) + .refine((v) => distinctColumns(v, MEDICATION_COLUMN_KEYS), { + message: "Each column can feed one field", + path: ["name"], + }); + +export type MedicationsCsvQuery = z.infer; + +export const RECORD_IMPORT_ROW_REASONS = [ + "missing_value", + "invalid_number", + "ambiguous_number", + "missing_unit", + "invalid_date", + "invalid_time", + "ambiguous_date", + "future_date", + "text_too_long", + "unit_mismatch", + "invalid_reference_range", + "invalid_times", + "missing_times", + "invalid_yes_no", + "as_needed_with_times", + "end_before_start", + "invalid_medication", + "oversized_row", + "row_limit", + "duplicate", +] as const; + +export type RecordImportRowReason = (typeof RECORD_IMPORT_ROW_REASONS)[number]; + +export const recordImportResultSchema = z + .object({ + kind: z.enum(["labs", "medications"]), + dryRun: z.boolean(), + rows: z.number().int().describe("Data rows read (the header excluded)."), + valid: z.number().int().describe("Rows that passed every check."), + inserted: z + .number() + .int() + .describe("Rows written; in a dry run, the rows that would be."), + duplicates: z + .number() + .int() + .describe( + "Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the same file therefore writes nothing.", + ), + failed: z + .number() + .int() + .describe("Rows refused; each is listed in `errors`."), + errors: z + .array( + z.object({ + line: z.number().int().describe("1-based line of the file."), + reason: z.enum(RECORD_IMPORT_ROW_REASONS), + field: z.string().optional().describe("The mapped field at fault."), + }), + ) + .describe("Refused and duplicate rows, in file order, at most 500."), + errorsTruncated: z.boolean(), + markers: z + .object({ + matched: z + .array( + z.object({ + name: z.string().describe("The name as the file writes it."), + marker: z.string().describe("The marker it resolves to."), + how: z.enum(["name", "alias", "catalog"]), + }), + ) + .describe( + "File names that resolve to an existing marker, at most 200.", + ), + created: z + .array(z.string()) + .describe("Names that become new markers, at most 200."), + }) + .optional() + .describe("Lab imports only: how the file's names meet the markers."), + preview: z + .array( + z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + ) + .describe( + "The first 20 valid rows as they will be stored (dry run only).", + ), + }) + .meta({ id: "RecordImportResult" }); + +export type RecordImportResult = z.infer; diff --git a/src/lib/validations/source-provenance.ts b/src/lib/validations/source-provenance.ts new file mode 100644 index 000000000..f3fcf880e --- /dev/null +++ b/src/lib/validations/source-provenance.ts @@ -0,0 +1,132 @@ +/** + * Wire schemas of the source-provenance reads (#1204, #1108, #576). + */ +import { z } from "zod/v4"; + +import { + measurementSourceEnum, + measurementTypeEnum, +} from "@/lib/validations/measurement"; + +export const PROVENANCE_MAX_DAYS = 366; + +export const dayProvenanceQuerySchema = z.object({ + type: measurementTypeEnum.describe("The metric."), + date: z.iso + .date() + .describe( + "The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for.", + ), +}); + +export const sourceComparisonQuerySchema = z + .object({ + type: measurementTypeEnum.describe("The metric."), + from: z.iso.date().describe("First UTC day, inclusive."), + to: z.iso.date().describe("Last UTC day, inclusive."), + a: measurementSourceEnum + .optional() + .describe("First source. Defaults to the source covering the most days."), + b: measurementSourceEnum + .optional() + .describe("Second source. Defaults to the next one."), + }) + .refine((q) => q.from <= q.to, { + message: "from must not be after to", + path: ["from"], + }) + .refine( + (q) => + (Date.parse(q.to) - Date.parse(q.from)) / 86_400_000 < + PROVENANCE_MAX_DAYS, + { message: `At most ${PROVENANCE_MAX_DAYS} days`, path: ["to"] }, + ); + +const pickReasonEnum = z + .enum(["only_source", "highest_on_ladder", "name_tiebreak", "not_ranked"]) + .describe( + "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this metric has no ladder, every source counts.", + ); + +const passReasonEnum = z + .enum(["lower_on_ladder", "not_on_ladder", "name_tiebreak"]) + .nullable() + .describe("Why this source was not the one picked; null for the picked one."); + +const sourceRowSchema = z.object({ + source: measurementSourceEnum, + rank: z + .number() + .int() + .describe("Position on the ladder, 0 = highest; 90 = not on the ladder."), + readings: z.number().int(), + value: z + .number() + .nullable() + .describe( + "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), the mean of the readings otherwise.", + ), + min: z.number().nullable(), + max: z.number().nullable(), + firstAt: z.string().describe("ISO instant of the first reading that day."), + lastAt: z.string(), + unit: z.string().nullable(), + devices: z + .array(z.string()) + .describe("Device kinds the readings carry; `unknown` when untagged."), +}); + +export const dayProvenanceResponseSchema = z + .object({ + type: measurementTypeEnum, + date: z.iso.date(), + aggregation: z.enum(["sum", "mean"]), + ranked: z.boolean(), + metricKey: z.string().nullable(), + ladder: z.array(measurementSourceEnum), + picked: measurementSourceEnum.nullable(), + pickReason: pickReasonEnum.nullable(), + sources: z.array(sourceRowSchema.extend({ pass: passReasonEnum })), + }) + .meta({ id: "DayProvenance" }); + +export const sourceComparisonResponseSchema = z + .object({ + type: measurementTypeEnum, + from: z.iso.date(), + to: z.iso.date(), + aggregation: z.enum(["sum", "mean"]), + ranked: z.boolean(), + ladder: z.array(measurementSourceEnum), + unit: z.string().nullable(), + sources: z.array( + z.object({ source: measurementSourceEnum, days: z.number().int() }), + ), + a: measurementSourceEnum.nullable(), + b: measurementSourceEnum.nullable(), + days: z.array( + z.object({ + date: z.iso.date(), + a: z.number().nullable(), + b: z.number().nullable(), + picked: measurementSourceEnum.nullable(), + }), + ), + summary: z.object({ + daysA: z.number().int(), + daysB: z.number().int(), + daysBoth: z.number().int(), + meanA: z.number().nullable(), + meanB: z.number().nullable(), + meanDifference: z.number().nullable(), + meanAbsoluteDifference: z.number().nullable(), + pickedA: z.number().int(), + pickedB: z.number().int(), + }), + }) + .meta({ id: "SourceComparison" }); + +export type DayProvenanceResponse = z.infer; +export type SourceComparisonResponse = z.infer< + typeof sourceComparisonResponseSchema +>; diff --git a/src/proxy.ts b/src/proxy.ts index d2f8e2e81..132e9c31a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -865,6 +865,6 @@ export const config = { // the matcher and `apiHandler` performs the proxy's duties for it. The // alternation must stay equal to that list; `proxy-body-limit.test.ts` // compiles this exactly as Next does and checks. - "/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)", + "/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/labs-csv|/medications-csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)", ], }; diff --git a/tests/integration/activity-calendar-read.test.ts b/tests/integration/activity-calendar-read.test.ts new file mode 100644 index 000000000..230281993 --- /dev/null +++ b/tests/integration/activity-calendar-read.test.ts @@ -0,0 +1,172 @@ +/** + * The activity calendar's reader against a real Postgres (D1036). + * + * - steps resolve to the source the person's ladder picks per day, so a day + * two devices counted shows one device's total, never the sum; + * - the day is the person's local day: a reading at 23:30 Berlin time lands + * on that day, not on the UTC day after; + * - a workout two devices recorded counts once, its minutes once; + * - the window is exact: nothing before `fromKey` or after `toKey`. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const USER = "activity-reader"; +const DAY = 86_400_000; +const NOW = new Date(); + +function daysAgo(n: number, hourUtc = 10): Date { + const d = new Date(NOW.getTime() - n * DAY); + d.setUTCHours(hourUtc, 0, 0, 0); + return d; +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + timezone: TZ, + sourcePriorityJson: { + metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] }, + }, + }, + }); + invalidateUserTimezone(USER); + + await prisma.measurement.createMany({ + data: [ + // Day 3: both devices; the ladder picks Fitbit's 8 000. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 9500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 8000, + unit: "steps", + source: "FITBIT", + measuredAt: daysAgo(3), + }, + // Day 2: Apple only, two slices that add up. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 8), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 4000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 15), + }, + // 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(5, 21), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 25, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 9), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 15, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 17), + }, + // Outside the window asked for below. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 777, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(40), + }, + ], + }); + + const start = daysAgo(3, 7); + await prisma.workout.createMany({ + data: [ + { + userId: USER, + sportType: "running", + startedAt: start, + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2400, + source: "APPLE_HEALTH", + externalId: "hk-1", + }, + { + userId: USER, + sportType: "running", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2370, + source: "FITBIT", + externalId: "fb-1", + }, + ], + }); +}); + +describe("readActivityDays", () => { + it("follows the ladder, the local day and the workout collapse", async () => { + const fromKey = userDayKey(daysAgo(10), TZ); + const toKey = userDayKey(NOW, TZ); + const { days } = await readActivityDays({ + userId: USER, + tz: TZ, + fromKey, + toKey, + now: NOW, + includeWorkouts: true, + }); + const by = new Map(days.map((d) => [d.date, d])); + + const d3 = by.get(userDayKey(daysAgo(3), TZ)); + expect(d3?.steps).toBe(8000); + expect(d3?.exerciseMinutes).toBe(40); + expect(d3?.workoutCount).toBe(1); + expect(d3?.workoutsBySport).toEqual({ running: 1 }); + expect(d3?.workoutMinutes).toBe(40); + + expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000); + expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200); + expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true); + expect(days.some((d) => d.steps === 777)).toBe(false); + }); +}); diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts new file mode 100644 index 000000000..665fc6da6 --- /dev/null +++ b/tests/integration/record-csv-import.test.ts @@ -0,0 +1,251 @@ +/** + * The lab and medication CSV imports against a real Postgres (#1215). + * + * - a dry run writes nothing (no reading, no marker, no medication) and + * reports what the real run then does; + * - names resolve to existing markers, new analytes mint one marker each, + * a reading in another unit is converted and keeps what the file said; + * - re-uploading the same file writes nothing: every row is a duplicate; + * - a refused row is reported by line and does not stop the rest; + * - medications take a daily schedule, reminders off, as-needed without + * schedule, and a dated window opens a course. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { textToStream } from "@/lib/import/csv-stream"; +import { importLabsCsv } from "@/lib/import/labs-csv"; +import { importMedicationsCsv } from "@/lib/import/medications-csv"; +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, +} from "@/lib/validations/record-import"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const prisma = getPrismaClient(); +const USER = "csv-import-user"; +const TZ = "Europe/Berlin"; + +const LABS = [ + "Analyt;Wert;Einheit;Datum;Referenz", + "Glucose;5,5;mmol/L;01.03.2025;3,9 - 5,5", + "Glukose;99;mg/dL;02.03.2025;", + "HbA1c;5,4;%;01.03.2025;4,0-5,6", + "Urinstatus Eiweiß;negativ;;01.03.2025;", + "Ferritin;1.234;ng/mL;01.03.2025;", + "Ferritin;85;;01.03.2025;", + "Neuer Marker;12;U/l;03.03.2025;", +].join("\n"); + +function labsQuery(dryRun: boolean) { + return labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + referenceRange: "4", + decimal: "comma", + dateOrder: "dmy", + dryRun: dryRun ? "1" : "0", + }); +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: TZ }, + }); + await prisma.biomarker.create({ + // No analyte key stored: the import derives it the way the write does. + data: { userId: USER, name: "Glucose", unit: "mg/dL" }, + }); + await prisma.biomarkerAlias.create({ + data: { + userId: USER, + biomarkerId: ( + await prisma.biomarker.findFirstOrThrow({ where: { userId: USER } }) + ).id, + name: "Glukose", + nameFolded: "glukose", + source: "MANUAL", + }, + }); +}); + +describe("lab CSV import", () => { + it("a dry run writes nothing and reports the plan", async () => { + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(LABS, 16), + maxBytes: 1_000_000, + query: labsQuery(true), + }); + expect(result).toMatchObject({ + rows: 7, + valid: 5, + inserted: 5, + failed: 2, + duplicates: 0, + }); + expect(result.errors).toEqual([ + { line: 6, reason: "ambiguous_number", field: "value" }, + { line: 7, reason: "missing_unit", field: "unit" }, + ]); + expect(result.markers?.created.sort()).toEqual([ + "HbA1c", + "Neuer Marker", + "Urinstatus Eiweiß", + ]); + expect(result.markers?.matched.map((m) => m.how).sort()).toEqual([ + "alias", + "name", + ]); + expect(await prisma.labResult.count({ where: { userId: USER } })).toBe(0); + expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(1); + }); + + it("the import converts, mints and keeps what the file said", async () => { + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(LABS, 16), + maxBytes: 1_000_000, + query: labsQuery(false), + }); + expect(result).toMatchObject({ inserted: 5, failed: 2 }); + const glucose = await prisma.labResult.findMany({ + where: { userId: USER, analyte: "Glucose" }, + orderBy: { takenAt: "asc" }, + }); + expect(glucose).toHaveLength(2); + expect(glucose[0].unit).toBe("mg/dL"); + expect(glucose[0].value).toBeCloseTo(99.1, 0); + expect(glucose[0].sourceValue).toBe(5.5); + expect(glucose[0].sourceUnit).toBe("mmol/L"); + expect(glucose[0].source).toBe("IMPORT"); + expect(glucose[1].value).toBe(99); + const text = await prisma.labResult.findFirstOrThrow({ + where: { userId: USER, valueText: "negativ" }, + }); + expect(text.value).toBeNull(); + expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(4); + }); + + it("re-uploading the same file writes nothing", async () => { + const before = await prisma.labResult.count({ where: { userId: USER } }); + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(LABS, 7), + maxBytes: 1_000_000, + query: labsQuery(false), + }); + expect(result).toMatchObject({ inserted: 0, duplicates: 5, failed: 2 }); + expect(await prisma.labResult.count({ where: { userId: USER } })).toBe( + before, + ); + }); + + it("refuses a unit that does not convert into the marker's", async () => { + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream( + "Analyt;Wert;Einheit;Datum\nGlucose;7;mmHg;05.03.2025\n", + ), + maxBytes: 1_000_000, + query: labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + decimal: "comma", + dateOrder: "dmy", + }), + }); + expect(result.errors).toEqual([ + { line: 2, reason: "unit_mismatch", field: "unit" }, + ]); + }); +}); + +describe("medication CSV import", () => { + const MEDS = [ + "name,dose,times,as needed,start,end", + "Ramipril,5 mg,08:00,,2025-01-01,", + "Metformin,500 mg,08:00 20:00,,,", + "Ibuprofen,400 mg,,yes,,", + "Vitamin D,1000 IE,,,,", + "ramipril,5 MG,08:00,,,", + ].join("\n"); + const query = (dryRun: boolean) => + medicationsCsvQuerySchema.parse({ + name: "0", + dose: "1", + times: "2", + asNeeded: "3", + startsOn: "4", + endsOn: "5", + dryRun: dryRun ? "1" : "0", + }); + + it("checks, then imports, then skips everything on a second upload", async () => { + const dry = await importMedicationsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(MEDS), + maxBytes: 100_000, + query: query(true), + }); + expect(dry).toMatchObject({ + rows: 5, + inserted: 3, + duplicates: 1, + failed: 1, + }); + expect(dry.errors).toEqual([ + { line: 5, reason: "missing_times", field: "times" }, + { line: 6, reason: "duplicate" }, + ]); + expect(await prisma.medication.count({ where: { userId: USER } })).toBe(0); + + const real = await importMedicationsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(MEDS), + maxBytes: 100_000, + query: query(false), + }); + expect(real).toMatchObject({ inserted: 3, duplicates: 1, failed: 1 }); + const meds = await prisma.medication.findMany({ + where: { userId: USER }, + include: { schedules: true, courses: true }, + orderBy: { name: "asc" }, + }); + const byName = new Map(meds.map((m) => [m.name, m])); + expect(byName.get("Metformin")?.schedules[0]).toMatchObject({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00", "20:00"], + }); + expect(byName.get("Metformin")?.notificationsEnabled).toBe(false); + expect(byName.get("Ibuprofen")).toMatchObject({ asNeeded: true }); + expect(byName.get("Ibuprofen")?.schedules).toHaveLength(0); + expect(byName.get("Ramipril")?.courses).toHaveLength(1); + + const again = await importMedicationsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(MEDS), + maxBytes: 100_000, + query: query(false), + }); + expect(again).toMatchObject({ inserted: 0, duplicates: 4 }); + }); +}); diff --git a/tests/integration/source-provenance.test.ts b/tests/integration/source-provenance.test.ts new file mode 100644 index 000000000..5e2c93052 --- /dev/null +++ b/tests/integration/source-provenance.test.ts @@ -0,0 +1,174 @@ +/** + * The provenance page names the source the charts actually use (#1204). + * + * Against a real Postgres: for every day of a two-source account, the source + * `readDayProvenance` reports as picked is the source the canonical CTE keeps + * (`canonicalMeasurementsCte`, which the series, rollups and summaries read + * through), under the default ladder and after the person reorders it. Sleep + * day values count asleep stages only, and the comparison lines the two + * sources up per day. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + buildSourceRankCase, + canonicalMeasurementsCte, +} from "@/lib/analytics/source-rank-sql"; +import { + readDayProvenance, + readSourceComparison, +} from "@/lib/provenance/source-provenance"; +import type { Prisma } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "provenance-user"; +const DAY = 86_400_000; +const base = new Date(Date.now() - 10 * DAY); +base.setUTCHours(0, 0, 0, 0); +const dayKey = (n: number) => + new Date(base.getTime() + n * DAY).toISOString().slice(0, 10); +const at = (n: number, hour: number) => + new Date(base.getTime() + n * DAY + hour * 3_600_000); + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); + const rows: Prisma.MeasurementCreateManyInput[] = []; + for (let d = 0; d < 5; d++) { + // Oura on every day, WHOOP on days 0, 2, 4. + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 300 + d, + unit: "min", + source: "OURA", + measuredAt: at(d, 3), + sleepStage: "CORE", + }); + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 90, + unit: "min", + source: "OURA", + measuredAt: at(d, 5), + sleepStage: "DEEP", + }); + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 30, + unit: "min", + source: "OURA", + measuredAt: at(d, 6), + sleepStage: "AWAKE", + }); + if (d % 2 === 0) { + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 420, + unit: "min", + source: "WHOOP", + measuredAt: at(d, 4), + sleepStage: "ASLEEP", + }); + } + rows.push({ + userId: USER, + type: "ACTIVITY_STEPS", + value: 8000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: at(d, 12), + }); + rows.push({ + userId: USER, + type: "ACTIVITY_STEPS", + value: 7000, + unit: "steps", + source: "FITBIT", + measuredAt: at(d, 12), + }); + } + await prisma.measurement.createMany({ data: rows }); +}); + +async function canonicalSource(type: string, date: string, priority: unknown) { + const rank = buildSourceRankCase(priority, '"type"', '"source"'); + const found = await prisma.$queryRawUnsafe>( + `SELECT DISTINCT m."source"::text AS source FROM (${canonicalMeasurementsCte(rank, "30 days")}) m + WHERE m."type"::text = $2 AND to_char(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') = $3`, + USER, + type, + date, + ); + return found.map((r) => r.source); +} + +describe("provenance against the canonical pick", () => { + it.each([ + ["default ladder", null], + [ + "reordered", + { + metricPriority: { + sleep: ["OURA", "WHOOP"], + steps: ["FITBIT", "APPLE_HEALTH"], + }, + }, + ], + ])("%s", async (_label, priority) => { + await prisma.user.update({ + where: { id: USER }, + data: { sourcePriorityJson: priority ?? undefined }, + }); + if (priority === null) { + await prisma.$executeRawUnsafe( + `UPDATE users SET source_priority_json = NULL WHERE id = $1`, + USER, + ); + } + for (const type of ["SLEEP_DURATION", "ACTIVITY_STEPS"] as const) { + for (let d = 0; d < 5; d++) { + const p = await readDayProvenance({ + userId: USER, + type, + date: dayKey(d), + }); + expect(await canonicalSource(type, dayKey(d), priority)).toEqual([ + p.picked, + ]); + } + } + }); + + it("counts asleep stages only and compares per day", async () => { + const p = await readDayProvenance({ + userId: USER, + type: "SLEEP_DURATION", + date: dayKey(1), + }); + expect(p.sources).toHaveLength(1); + expect(p.sources[0].value).toBe(301 + 90); + expect(p.pickReason).toBe("only_source"); + + const c = await readSourceComparison({ + userId: USER, + type: "SLEEP_DURATION", + from: dayKey(0), + to: dayKey(4), + a: "OURA", + b: "WHOOP", + }); + expect(c.summary.daysA).toBe(5); + expect(c.summary.daysB).toBe(3); + expect(c.summary.daysBoth).toBe(3); + expect(c.days[1]).toMatchObject({ a: 391, b: null, picked: "OURA" }); + }); +});