fix(strain): count one source per session and per day

Two rows of one workout slot from sources off the ladder were both
kept by the picker and their impulses added. Only one source's rows
now count per slot, a source off the ladder ranking below every
listed one. The day's active energy, the strain fallback signal,
summed every source; it now goes through the activeEnergy source pick
and keeps one source's total.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 23:25:45 +02:00
1 parent cfd7c18d48
commit 733b00ee1a
2 files changed
+140 -10

No files matched your search

@@ -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<number | { value: number; source: string }>;
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 = {
+79 -7
View File
@@ -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<string, number>();
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<string, { picked: number; dropped: number }>();
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<string, number>; 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;