feat(measurements): show where a metric's values come from

A read-only view per metric and UTC day: every source with readings, its
day value, its place in the person's source priority, and which source
the charts use with the reason. A second card lines two sources up over
30, 90 or 365 days. The pick is explained from the rank the canonical
SQL returns, so the page cannot disagree with the charts. Linked from
the metric pages and from Settings > Source priority.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:57:05 +02:00
1 parent 6f1232c733
commit c94766efc3
34 files changed
+3281 -1

No files matched your search

+908
View File
@@ -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:
+121
View File
@@ -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/);
});
});
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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": "이 값의 출처"
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+9 -1
View File
@@ -1077,6 +1077,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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.",
@@ -2176,7 +2184,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
* providers: two guardian-only entries. 266 -> 268.
*/
const FROZEN_ENTRY_COUNT = 269;
const FROZEN_ENTRY_COUNT = 271;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -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);
});
@@ -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);
});
+3
View File
@@ -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={<HeartPulse className="h-5 w-5" />}
/>
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="BLOOD_PRESSURE_SYS" />
</SubPageShell>
);
}
+3
View File
@@ -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. */}
<SourceProvenanceLink type="PULSE" />
</SubPageShell>
);
}
+3
View File
@@ -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() {
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="SLEEP_DURATION" />
</SubPageShell>
);
}
+44
View File
@@ -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 (
<SourceProvenanceView
initialType={type && isProvenanceType(type) ? type : "ACTIVITY_STEPS"}
initialDate={date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null}
/>
);
}
export default function InsightsSourcesPage() {
const { t } = useTranslations();
return (
<SubPageShell
title={t("sourceProvenance.title")}
description={t("sourceProvenance.description")}
>
<Suspense fallback={null}>
<SourcesPageBody />
</Suspense>
</SubPageShell>
);
}
+3
View File
@@ -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={<Scale className="h-5 w-5" />}
/>
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="WEIGHT" />
</SubPageShell>
);
}
@@ -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 ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{/* v1.44 (#1204) — where the values come from, for a metric the
source priority decides for. Nothing for any other metric. */}
<SourceProvenanceLink type={measurementType} />
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
panel). Mounted on the data-bearing branch only. */}
{afterChart}
@@ -0,0 +1,176 @@
/**
* `<SourceProvenanceView>` 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;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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(
<SourceProvenanceView
initialType="SLEEP_DURATION"
initialDate="2026-10-01"
/>,
);
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(
<SourceProvenanceView
initialType="SLEEP_DURATION"
initialDate="2026-10-01"
/>,
);
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(
<SourceProvenanceView initialType="SLEEP_DURATION" initialDate={null} />,
);
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(<SourceProvenanceLink type="ACTIVITY_STEPS" />)).toContain(
"/insights/sources?type=ACTIVITY_STEPS",
);
expect(render(<SourceProvenanceLink type="BLOOD_GLUCOSE" />)).toBe("");
});
});
@@ -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 (
<InlineAction asChild tone="primary" size="xs">
<Link
href={`/insights/sources?type=${type}`}
data-slot="source-provenance-link"
>
{t("sourceProvenance.metricLink")}
</Link>
</InlineAction>
);
}
@@ -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<ProvenanceType>(initialType);
const [date, setDate] = useState<string>(initialDate ?? utcToday());
const [range, setRange] = useState<RangeDays>("30");
const [a, setA] = useState<string | undefined>(undefined);
const [b, setB] = useState<string | undefined>(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 (
<div className="space-y-6" data-slot="source-provenance">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="provenance-type">
{t("sourceProvenance.metric")}
</Label>
<NativeSelect
id="provenance-type"
data-slot="provenance-type"
value={type}
onChange={(e) => {
setType(e.target.value as ProvenanceType);
setA(undefined);
setB(undefined);
}}
>
{sortedTypes.map((value) => (
<option key={value} value={value}>
{typeLabel(value)}
</option>
))}
</NativeSelect>
</div>
<div className="grid gap-1.5">
<Label htmlFor="provenance-date">{t("sourceProvenance.day")}</Label>
<DateField
id="provenance-date"
value={date}
max={to}
onChange={(value) => value && setDate(value)}
/>
</div>
</div>
<Card data-slot="provenance-day">
<CardHeader>
<TileHeader icon={Layers} title={t("sourceProvenance.dayTitle")} />
</CardHeader>
<CardContent className="space-y-3">
{day.isError ? (
<QueryErrorRow
inset
error={day.error}
message={t("sourceProvenance.loadError")}
onRetry={() => day.refetch()}
/>
) : !day.data ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : day.data.sources.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("sourceProvenance.dayEmpty")}
data-slot="provenance-day-empty"
/>
) : (
<>
<p className="text-sm" data-slot="provenance-verdict">
{day.data.pickReason === "not_ranked"
? t("sourceProvenance.reason.not_ranked")
: t(
`sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`,
{
source: sourceLabel(day.data.picked ?? ""),
},
)}
</p>
<ul className="divide-border divide-y rounded-lg border">
{day.data.sources.map((row) => (
<li
key={row.source}
data-slot="provenance-source-row"
data-source={row.source}
data-picked={
row.source === day.data?.picked ? "true" : "false"
}
className="flex items-start justify-between gap-3 p-3"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="flex flex-wrap items-center gap-2 text-sm font-medium">
{sourceLabel(row.source)}
{row.source === day.data?.picked ? (
<TagChip>{t("sourceProvenance.used")}</TagChip>
) : null}
</p>
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.readings", {
count: fmt.integer(row.readings),
devices: row.devices.join(", "),
})}
</p>
{row.pass ? (
<p className="text-muted-foreground text-xs">
{t(`sourceProvenance.pass.${row.pass}`)}
</p>
) : null}
</div>
<p className="shrink-0 text-sm tabular-nums">
{formatValue(row.value, day.data?.aggregation ?? "mean")}{" "}
<span className="text-muted-foreground text-xs">
{row.unit}
</span>
</p>
</li>
))}
</ul>
{day.data.ranked ? (
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.ladder", {
ladder: day.data.ladder.map(sourceLabel).join(", "),
})}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.utcNote")}
</p>
</>
)}
<InlineAction asChild tone="primary" size="xs">
<Link href="/settings/sources" data-slot="provenance-settings-link">
{t("sourceProvenance.openSettings")}
</Link>
</InlineAction>
</CardContent>
</Card>
<Card data-slot="provenance-compare">
<CardHeader>
<TileHeader
icon={GitCompareArrows}
title={t("sourceProvenance.compareTitle")}
right={
<Segmented
size="sm"
label={t("sourceProvenance.range")}
slot="provenance-range"
value={range}
onChange={setRange}
options={[
{
value: "30",
label: t("sourceProvenance.rangeDays", { days: 30 }),
},
{
value: "90",
label: t("sourceProvenance.rangeDays", { days: 90 }),
},
{
value: "365",
label: t("sourceProvenance.rangeDays", { days: 365 }),
},
]}
/>
}
/>
</CardHeader>
<CardContent className="space-y-4">
{compare.isError ? (
<QueryErrorRow
inset
error={compare.error}
message={t("sourceProvenance.loadError")}
onRetry={() => compare.refetch()}
/>
) : !compare.data ? (
<Skeleton className="h-32 w-full rounded-lg" />
) : compare.data.sources.length < 2 ? (
<EmptyState
variant="plain"
size="compact"
title={t("sourceProvenance.compareNeedsTwo")}
data-slot="provenance-compare-empty"
/>
) : (
<>
<div className="grid grid-cols-2 gap-4">
{(["a", "b"] as const).map((side) => (
<div key={side} className="grid gap-1.5">
<Label htmlFor={`provenance-${side}`}>
{t(`sourceProvenance.side.${side}`)}
</Label>
<NativeSelect
id={`provenance-${side}`}
data-slot={`provenance-${side}`}
value={
(side === "a" ? compare.data?.a : compare.data?.b) ?? ""
}
onChange={(e) =>
side === "a"
? setA(e.target.value)
: setB(e.target.value)
}
>
{compare.data?.sources.map((s) => (
<option key={s.source} value={s.source}>
{sourceLabel(s.source)}
</option>
))}
</NativeSelect>
</div>
))}
</div>
<dl
className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm"
data-slot="provenance-compare-summary"
>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.daysBoth")}
</dt>
<dd className="tabular-nums">
{fmt.integer(compare.data.summary.daysBoth)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanOf", {
source: sourceLabel(compare.data.a ?? ""),
})}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanA,
compare.data.aggregation,
)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanOf", {
source: sourceLabel(compare.data.b ?? ""),
})}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanB,
compare.data.aggregation,
)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanAbsDiff")}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanAbsoluteDifference,
"mean",
)}{" "}
<span className="text-muted-foreground text-xs">
{compare.data.unit}
</span>
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.pickedDays")}
</dt>
<dd className="tabular-nums">
{t("sourceProvenance.pickedSplit", {
a: fmt.integer(compare.data.summary.pickedA),
b: fmt.integer(compare.data.summary.pickedB),
})}
</dd>
</dl>
<div className="max-h-80 overflow-y-auto overscroll-contain rounded-lg border">
<table
className="w-full text-sm"
data-slot="provenance-compare-table"
>
<thead className="bg-muted text-muted-foreground sticky top-0 text-xs">
<tr>
<th scope="col" className="p-2 text-left font-medium">
{t("sourceProvenance.day")}
</th>
<th scope="col" className="p-2 text-right font-medium">
{sourceLabel(compare.data.a ?? "")}
</th>
<th scope="col" className="p-2 text-right font-medium">
{sourceLabel(compare.data.b ?? "")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{[...compare.data.days].reverse().map((d) => (
<tr key={d.date} data-slot="provenance-compare-row">
<th scope="row" className="p-2 text-left font-normal">
<InlineAction
type="button"
tone="primary"
size="sm"
data-slot="provenance-compare-day"
onClick={() => setDate(d.date)}
>
{fmt.dateShortSmartCalendar(d.date)}
</InlineAction>
</th>
{(["a", "b"] as const).map((side) => {
const source = compare.data?.[side];
const used = source !== null && d.picked === source;
return (
<td
key={side}
className={
used
? "p-2 text-right font-semibold tabular-nums"
: "p-2 text-right tabular-nums"
}
>
{formatValue(
d[side],
compare.data?.aggregation ?? "mean",
)}
{used ? (
<span className="sr-only">
{" "}
{t("sourceProvenance.used")}
</span>
) : null}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.compareNote")}
</p>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -679,6 +679,19 @@ export function SourcesSection() {
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
{/* 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. */}
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.settingsHint")}{" "}
<Link
href="/insights/sources"
className="text-primary underline underline-offset-2"
data-slot="sources-provenance-cross-link"
>
{t("sourceProvenance.settingsHintLink")}
</Link>
</p>
{/* One action row, last in the card (design standards §12). The
reset used to ride the header's status slot, which carries
+48
View File
@@ -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<DayProvenanceResponse>(
`/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<SourceComparisonResponse>(
`/api/measurements/provenance/compare?${params}`,
);
},
staleTime: 60_000,
});
}
+2
View File
@@ -28,6 +28,7 @@ import {
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";
@@ -107,6 +108,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...measurementPaths,
...workoutPaths,
...activityPaths,
...provenancePaths,
...devicePaths,
...medicationPaths,
...profilePaths,
+67
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
@@ -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);
});
});
@@ -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 },
]);
});
});
+35
View File
@@ -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);
}
+415
View File
@@ -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<SourceRow & { pass: PassReason | null }>;
}
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<Array<RawSourceRow & { day: string }>> {
if (!ENUM_RE.test(type)) {
throw new Error(`unsafe enum literal for SQL splice: ${type}`);
}
const rank = buildSourceRankCase(priorityJson, '"type"', '"source"');
return prisma.$queryRawUnsafe<Array<RawSourceRow & { day: string }>>(
`
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<PassReason | null>;
} {
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<DayProvenance> {
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<SourceRow & { day: string }>;
a?: string | null;
b?: string | null;
}): SourceComparison {
const { type, from, to, ranked, ladder, rows } = args;
const byDay = new Map<string, Array<SourceRow & { day: string }>>();
const coverage = new Map<string, number>();
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<SourceComparison> {
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,
});
}
+2
View File
@@ -48,6 +48,7 @@ import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
import { activityKeys } from "./activity";
import { provenanceKeys } from "./provenance";
export const queryKeys = {
...authKeys,
@@ -62,6 +63,7 @@ export const queryKeys = {
...integrationKeys,
...workoutKeys,
...activityKeys,
...provenanceKeys,
...settingsKeys,
...cycleKeys,
...measurementReminderKeys,
+15
View File
@@ -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,
};
+132
View File
@@ -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<typeof dayProvenanceResponseSchema>;
export type SourceComparisonResponse = z.infer<
typeof sourceComparisonResponseSchema
>;
+174
View File
@@ -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<Array<{ source: string }>>(
`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" });
});
});