diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1a90c78ad..0fd3b5992 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3606,6 +3606,22 @@ paths: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -3881,6 +3897,22 @@ paths: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA deviceTypePriority: type: object properties: @@ -14406,6 +14438,22 @@ components: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -14681,6 +14729,22 @@ components: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object @@ -15002,6 +15066,22 @@ components: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -15277,6 +15357,22 @@ components: - NIGHTSCOUT - POLAR - OURA + stress: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object diff --git a/messages/de.json b/messages/de.json index 40d7ddbd8..364f6d650 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 max", "skinTemperature": "Hauttemperatur", "respiratoryRate": "Atemfrequenz", - "recovery": "Erholung" + "recovery": "Erholung", + "stress": "Stress" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/en.json b/messages/en.json index 54241ad76..b1600d2b8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 max", "skinTemperature": "Skin temperature", "respiratoryRate": "Respiratory rate", - "recovery": "Recovery" + "recovery": "Recovery", + "stress": "Stress" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/es.json b/messages/es.json index 4b8561ed3..6e95bb739 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 máx.", "skinTemperature": "Temperatura de la piel", "respiratoryRate": "Frecuencia respiratoria", - "recovery": "Recuperación" + "recovery": "Recuperación", + "stress": "Estrés" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/fr.json b/messages/fr.json index 71ec8f395..0efc0cb1d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 max", "skinTemperature": "Température cutanée", "respiratoryRate": "Fréquence respiratoire", - "recovery": "Récupération" + "recovery": "Récupération", + "stress": "Stress" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/it.json b/messages/it.json index 712b5c259..32468cad7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 max", "skinTemperature": "Temperatura cutanea", "respiratoryRate": "Frequenza respiratoria", - "recovery": "Recupero" + "recovery": "Recupero", + "stress": "Stress" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/pl.json b/messages/pl.json index 832a4d5d2..07834c1a3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3870,7 +3870,8 @@ "vo2Max": "VO2 max", "skinTemperature": "Temperatura skóry", "respiratoryRate": "Częstość oddechów", - "recovery": "Regeneracja" + "recovery": "Regeneracja", + "stress": "Stres" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 237b019c7..c07fa2ae4 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -87,6 +87,8 @@ const METRIC_LABEL_KEYS: Record = { skinTemperature: "settings.sections.sources.metrics.skinTemperature", respiratoryRate: "settings.sections.sources.metrics.respiratoryRate", recovery: "settings.sections.sources.metrics.recovery", + // v1.18.10 I-5 — native-vs-derived stress ladder. + stress: "settings.sections.sources.metrics.stress", }; const SOURCE_LABEL_KEYS: Record = { diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index 5aac2e1c4..6b78f9d2a 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -31,7 +31,7 @@ import { * absent: they only ever have one source per (type, day), so the collapse is * a no-op for them and they fall into the CASE's ELSE branch. */ -const RANKED_TYPES: readonly MeasurementType[] = [ +export const RANKED_TYPES: readonly MeasurementType[] = [ "RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", "RESPIRATORY_RATE", @@ -45,6 +45,10 @@ const RANKED_TYPES: readonly MeasurementType[] = [ "PULSE", "VO2_MAX", "RECOVERY_SCORE", + // v1.18.10 I-5 — STRESS_SCORE carries a source-priority ladder (`stress`) + // so the SQL collapse ranks a future device-native producer above the + // COMPUTED proxy deterministically. No-op until a second producer lands. + "STRESS_SCORE", "SLEEP_DURATION", "ACTIVITY_STEPS", "ACTIVE_ENERGY_BURNED", diff --git a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts index d4a63684f..eb993ce5d 100644 --- a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts +++ b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts @@ -6,8 +6,10 @@ import { CUMULATIVE_DAY_SUM_TYPES, cumulativeMetricKey, isCumulativeDaySumType, + metricKeyForType, pickCumulativeDaySum, } from "../cumulative-day-sum"; +import { RANKED_TYPES } from "@/lib/analytics/source-rank-sql"; /** * v1.4.36 W4c — pickCumulativeDaySum unit tests. @@ -156,3 +158,22 @@ describe("cumulativeMetricKey", () => { expect(cumulativeMetricKey("TIME_IN_DAYLIGHT")).toBeNull(); }); }); + +describe("metricKeyForType — source-priority ladder coverage (v1.18.10 I-5)", () => { + // Every type in RANKED_TYPES drives the SQL source-collapse CASE + // (`buildSourceRankCase` skips a type whose `metricKeyForType` is null). + // A ranked type that resolves to null would make `collapseRollupRowsBySource` + // fall to the alphabetical rank-90 tiebreak — nondeterministic the instant a + // second producer for that type is enabled. Pin the contract so a future + // ranked type cannot ship without its metric key. + it("resolves every RANKED_TYPES member to a non-null SourcePriorityMetricKey", () => { + for (const type of RANKED_TYPES) { + expect(metricKeyForType(type)).not.toBeNull(); + } + }); + + it("ladders STRESS_SCORE so the COMPUTED-vs-device producer is deterministic", () => { + expect(metricKeyForType("STRESS_SCORE")).toBe("stress"); + expect(RANKED_TYPES).toContain("STRESS_SCORE"); + }); +}); diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts index e5c41947f..c4c55b1d1 100644 --- a/src/lib/measurements/cumulative-day-sum.ts +++ b/src/lib/measurements/cumulative-day-sum.ts @@ -172,6 +172,12 @@ export function metricKeyForType( return "vo2Max"; case "RECOVERY_SCORE": return "recovery"; + case "STRESS_SCORE": + // v1.18.10 I-5 — ladder the COMPUTED-vs-device stress producer so the + // rollup source collapse is deterministic the instant a second producer + // (Oura `daily_stress`) is enabled, rather than falling to the + // alphabetical rank-90 tiebreak in `collapseRollupRowsBySource`. + return "stress"; case "SLEEP_DURATION": return "sleep"; default: diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 128516207..b54781693 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -131,6 +131,13 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [ // `RECOVERY_SCORE` rows distinguished by source — this ladder lets the // same picker resolve native-above-proxy without a second engine. "recovery", + // v1.18.10 I-5 — native-vs-derived stress. HealthLog computes a COMPUTED + // HRV-derived `STRESS_SCORE` proxy; a device-native producer (Oura + // `daily_stress`) is the deferred second source. Laddering it now makes the + // picker deterministic the instant a second producer is enabled, instead of + // collapsing to the alphabetical rank-90 tiebreak. Single-producer today, + // so the ladder is a no-op until the second producer lands. + "stress", ] as const; export type SourcePriorityMetricKey = @@ -297,6 +304,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // fallback for users without a strap. v1.17.0 — Oura readiness + Polar // nightly recovery slot below WHOOP and above the computed proxy. recovery: ["WHOOP", "OURA", "POLAR", "COMPUTED"], + // v1.18.10 I-5 — native-vs-derived stress. Oura's device-native + // `daily_stress` (the deferred second producer) outranks HealthLog's + // COMPUTED HRV-derived proxy; the proxy is the fallback for users without a + // device-native stress signal. Single-producer today. + stress: ["OURA", "COMPUTED"], }; /**