diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 8ae7afe7f..0fcf0c875 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -5106,6 +5106,311 @@ paths:
"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:
@@ -49690,6 +49995,609 @@ components:
- 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:
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 077caaefb..84b30f1e9 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/en.json b/messages/en.json
index 41c212bfc..fa195cc63 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/es.json b/messages/es.json
index 7a297a960..9d70fdebf 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/fr.json b/messages/fr.json
index e9c8f45c1..bf6037632 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/it.json b/messages/it.json
index c522cb6d7..79fea56c9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/ko.json b/messages/ko.json
index 91597e3e2..72a30343d 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -13539,5 +13539,47 @@
"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": "이 값의 출처"
}
}
diff --git a/messages/nl.json b/messages/nl.json
index 85559a6eb..fdbb3164d 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 0e77760d4..f39398420 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -13539,5 +13539,47 @@
"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"
}
}
diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts
index cdd3e4159..79a51c137 100644
--- a/src/__tests__/sharing-surface-guard.test.ts
+++ b/src/__tests__/sharing-surface-guard.test.ts
@@ -1077,6 +1077,14 @@ const DELEGABLE_ROUTES: Record
+ {day.data.pickReason === "not_ranked"
+ ? t("sourceProvenance.reason.not_ranked")
+ : t(
+ `sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`,
+ {
+ source: sourceLabel(day.data.picked ?? ""),
+ },
+ )}
+
+ {sourceLabel(row.source)}
+ {row.source === day.data?.picked ? (
+
+ {t("sourceProvenance.readings", {
+ count: fmt.integer(row.readings),
+ devices: row.devices.join(", "),
+ })}
+
+ {t(`sourceProvenance.pass.${row.pass}`)}
+
+ {formatValue(row.value, day.data?.aggregation ?? "mean")}{" "}
+
+ {row.unit}
+
+
+ {t("sourceProvenance.ladder", {
+ ladder: day.data.ladder.map(sourceLabel).join(", "),
+ })}
+
+ {t("sourceProvenance.utcNote")}
+
+ {t("sourceProvenance.compareNote")}
+
+ {day.data.sources.map((row) => (
+
+ {day.data.ranked ? (
+
+
+
+
+
+
+
+
+
+ {[...compare.data.days].reverse().map((d) => (
+
+ {t("sourceProvenance.day")}
+
+
+ {sourceLabel(compare.data.a ?? "")}
+
+
+ {sourceLabel(compare.data.b ?? "")}
+
+
+
+ ))}
+
+
+
+ {(["a", "b"] as const).map((side) => {
+ const source = compare.data?.[side];
+ const used = source !== null && d.picked === source;
+ return (
+
+ {formatValue(
+ d[side],
+ compare.data?.aggregation ?? "mean",
+ )}
+ {used ? (
+
+ {" "}
+ {t("sourceProvenance.used")}
+
+ ) : null}
+
+ );
+ })}
+
+ {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-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