diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index fcf7173fb..03ea6f010 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -33,6 +33,8 @@ import { resolvePersonalReference, persistStrainScore, canonicalWorkoutTrimps, + canonicalDayActiveEnergy, + STRAIN_ACTIVE_ENERGY_REFERENCE, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +154,7 @@ describe("saturateToScore", () => { }); function makePrisma(opts: { - energy?: number[]; + energy?: Array; workouts?: Array<{ id: string; samples: { samples: unknown } | null; @@ -172,8 +174,9 @@ function makePrisma(opts: { trainingDays?: number; }>; }) { - const energyRows = (opts.energy ?? []).map((value, i) => ({ - value, + const energyRows = (opts.energy ?? []).map((entry, i) => ({ + ...(typeof entry === "number" ? { value: entry } : entry), + measuredAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), id: `e${i}`, })); const measurementFindMany = vi.fn().mockResolvedValue(energyRows); @@ -577,6 +580,21 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { ).toEqual([15, 20, 40]); }); + it("never adds a source off the ladder to another source's session", () => { + // WHOOP and IMPORT are both off the workout ladder, so the picker keeps + // both rows of the slot: one session, the larger impulse. + const rows = [row("w", "WHOOP", 50), row("i", "IMPORT", 45, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + + it("still adds two sessions of one source in the same slot", () => { + const rows = [ + { ...row("w1", "WHOOP", 30), durationSec: 600 }, + { ...row("w2", "WHOOP", 20, 2), durationSec: 900 }, + ]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + it("is empty when nothing carries an impulse", () => { expect( canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), @@ -584,6 +602,46 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { }); }); +describe("canonicalDayActiveEnergy — one source per day", () => { + const at = new Date("2026-06-01T09:00:00.000Z"); + const e = (source: string, value: number) => ({ + source: source as "APPLE_HEALTH", + value, + measuredAt: at, + }); + + it("keeps the ladder's first source, not the sum of two", () => { + expect( + canonicalDayActiveEnergy( + [e("APPLE_HEALTH", 200), e("OURA", 280), e("APPLE_HEALTH", 100)], + null, + ), + ).toBe(300); + }); + + it("keeps the larger total when no source is on the ladder", () => { + expect( + canonicalDayActiveEnergy([e("WHOOP", 200), e("IMPORT", 150)], null), + ).toBe(200); + }); + + it("drives the active-energy fallback score", async () => { + const { prisma } = makePrisma({ + rhr: 50, + energy: [ + { value: 300, source: "APPLE_HEALTH" }, + { value: 290, source: "OURA" }, + ], + workouts: [], + }); + const result = await persistStrainScore(prisma, "user-1", NOW); + expect(result.reason).toBe("active_energy_fallback"); + expect(result.score).toBe( + saturateToScore(300, STRAIN_ACTIVE_ENERGY_REFERENCE), + ); + }); +}); + describe("persistStrainScore — duplicate sessions", () => { it("scores a session stored twice the same as the session stored once", async () => { const series = { diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 52e02f51a..ee87a461b 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -75,8 +75,13 @@ import { shiftDateKey } from "@/lib/tz/format"; import { pickCanonicalWorkoutRows, workoutDuplicateSlotKey, + workoutSourceLadder, type WorkoutPickerRow, } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + pickCanonicalSourceRows, + type SourcePickerRow, +} from "@/lib/analytics/source-priority"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,10 +418,16 @@ export async function computeStrainScore( deletedAt: null, measuredAt: { gte: dayStart, lt: dayEnd }, }, - select: { value: true }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { + value: true, + source: true, + deviceType: true, + measuredAt: true, + type: true, + }, take: 5000, }); - const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); // The day's workouts + their HR series, with the session identity the // cross-source picker needs: one run recorded by the watch and synced again @@ -441,6 +452,13 @@ export async function computeStrainScore( }), ]); + // The watch and the phone relay (or a ring) both write the day's active + // energy: one source per day, the same pick the energy chart applies. + const dayActiveEnergy = canonicalDayActiveEnergy( + energyRows, + account?.sourcePriorityJson ?? null, + ); + const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -529,6 +547,31 @@ export async function computeStrainScore( }; } +/** + * The day's active energy from ONE source. The rows of a single scored day go + * through the cumulative-metric picker on the `activeEnergy` ladder (source, + * then device type); when no source on the ladder is present the picker keeps + * every row, so the sources left are then compared by their day total and the + * largest kept: never two sources' totals added. Pure; exported for tests. + */ +export function canonicalDayActiveEnergy< + T extends SourcePickerRow & { value: number }, +>(rows: readonly T[], priorityJson: unknown): number { + const { canonicalRows } = pickCanonicalSourceRows( + rows, + "activeEnergy", + priorityJson, + () => "day", + ); + const bySource = new Map(); + for (const row of canonicalRows) { + bySource.set(row.source, (bySource.get(row.source) ?? 0) + row.value); + } + let best = 0; + for (const total of bySource.values()) best = Math.max(best, total); + return best; +} + /** * One training impulse per workout SESSION, not per stored row. * @@ -542,6 +585,13 @@ export async function computeStrainScore( * rate: the winning row can be one without a series while its dropped twin * carries one. So the impulse of a slot is the sum over its picked rows, and * only when that is zero the largest impulse among the slot's dropped rows. + * + * The picker keeps every row of a slot none of whose sources is on the + * ladder, so the picked rows of one slot can come from several sources. Only + * one source's rows count: the best-ranked on the ladder, a source off the + * ladder ranking below every listed one, and among sources off the ladder + * the one with the largest impulse. Rows of that one source still add up + * (two distinct sessions of the same sport in one five-minute slot). * Returns the positive impulses, one per session. Pure; exported for tests. */ export function canonicalWorkoutTrimps< @@ -556,20 +606,42 @@ export function canonicalWorkoutTrimps< const picked = new Set( pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), ); - const slots = new Map(); + const ladder = workoutSourceLadder(priorityJson); + const rankOf = (source: string) => { + const index = ladder.indexOf(source as (typeof ladder)[number]); + return index === -1 ? ladder.length : index; + }; + const slots = new Map< + string, + { picked: Map; dropped: number } + >(); for (const row of rows) { const key = workoutDuplicateSlotKey(row); - const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 }; const trimp = trimpOf(row); if (trimp > 0) { - if (picked.has(row.id)) slot.picked += trimp; - else slot.dropped = Math.max(slot.dropped, trimp); + if (picked.has(row.id)) { + slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); + } else { + slot.dropped = Math.max(slot.dropped, trimp); + } } slots.set(key, slot); } const out: number[] = []; for (const slot of slots.values()) { - const trimp = slot.picked > 0 ? slot.picked : slot.dropped; + let best: { rank: number; trimp: number } | null = null; + for (const [source, trimp] of slot.picked) { + const rank = rankOf(source); + if ( + best === null || + rank < best.rank || + (rank === best.rank && trimp > best.trimp) + ) { + best = { rank, trimp }; + } + } + const trimp = best !== null ? best.trimp : slot.dropped; if (trimp > 0) out.push(trimp); } return out;