Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 78 additions & 59 deletions app/lib/history-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,27 +100,80 @@ function nonNegative(value: number) {
return Number.isFinite(value) && value >= 0 ? value : 0;
}

function maxNullable(values: Array<number | null>) {
const available = values.filter((value): value is number => value !== null);
return available.length ? Math.max(...available) : null;
}

function trendValue(point: ExerciseTrendPoint, metric: ExerciseTrendMetric) {
if (metric === "weight") return point.bestWeight;
if (metric === "duration") return point.longestDurationSeconds;
if (metric === "repetitions") return point.bestRepetitions;
return point.completedExecutions;
}

function selectTrendMetric(points: ExerciseTrendPoint[]): ExerciseTrendMetric {
if (points.some((point) => point.bestWeight !== null)) return "weight";
if (points.some((point) => point.longestDurationSeconds !== null)) {
return "duration";
}
if (points.some((point) => point.bestRepetitions !== null)) {
return "repetitions";
function summarizeExercisePoints(points: ExerciseTrendPoint[]) {
let bestWeight: number | null = null;
let repetitionsAtBestWeight: number | null = null;
let bestRepetitions: number | null = null;
let longestDurationSeconds: number | null = null;
let completedExecutions = 0;
let totalWorkingVolume = 0;
let rpeCount = 0;
let rpeTotal = 0;

for (const point of points) {
completedExecutions += point.completedExecutions;
totalWorkingVolume += point.workingVolume;
rpeCount += point.rpeCount;
rpeTotal += (point.averageRpe ?? 0) * point.rpeCount;

if (point.bestWeight !== null) {
if (bestWeight === null || point.bestWeight > bestWeight) {
bestWeight = point.bestWeight;
repetitionsAtBestWeight = point.repetitionsAtBestWeight;
} else if (
point.bestWeight === bestWeight &&
point.repetitionsAtBestWeight !== null
) {
repetitionsAtBestWeight = Math.max(
repetitionsAtBestWeight ?? 0,
point.repetitionsAtBestWeight,
);
}
}
if (
point.bestRepetitions !== null &&
(bestRepetitions === null || point.bestRepetitions > bestRepetitions)
) {
bestRepetitions = point.bestRepetitions;
}
if (
point.longestDurationSeconds !== null &&
(longestDurationSeconds === null ||
point.longestDurationSeconds > longestDurationSeconds)
) {
longestDurationSeconds = point.longestDurationSeconds;
}
}
return "completions";

const trendMetric: ExerciseTrendMetric =
bestWeight !== null
? "weight"
: longestDurationSeconds !== null
? "duration"
: bestRepetitions !== null
? "repetitions"
: "completions";
return {
bestWeight,
repetitionsAtBestWeight,
bestRepetitions,
longestDurationSeconds,
completedExecutions,
totalWorkingVolume,
averageRpe: rpeCount ? rpeTotal / rpeCount : null,
rpeCount,
trendMetric,
metricPoints: points.filter(
(point) => trendValue(point, trendMetric) !== null,
),
};
}

function makeExercisePoint(
Expand Down Expand Up @@ -303,56 +356,22 @@ export function deriveHistoryAnalytics(

const exercises = Array.from(exerciseGroups.values())
.map((group): ExerciseAnalytics => {
const trendMetric = selectTrendMetric(group.points);
const metricPoints = group.points.filter(
(point) => trendValue(point, trendMetric) !== null,
);
const bestWeight = maxNullable(
group.points.map((point) => point.bestWeight),
);
const rpeCount = group.points.reduce(
(total, point) => total + point.rpeCount,
0,
);
const rpeTotal = group.points.reduce(
(total, point) =>
total + (point.averageRpe ?? 0) * point.rpeCount,
0,
);
const summary = summarizeExercisePoints(group.points);
return {
id: group.id,
name: group.name,
recordedSessions: group.points.length,
completedExecutions: group.points.reduce(
(total, point) => total + point.completedExecutions,
0,
),
bestWeight,
repetitionsAtBestWeight:
bestWeight === null
? null
: maxNullable(
group.points.map((point) =>
point.bestWeight === bestWeight
? point.repetitionsAtBestWeight
: null,
),
),
bestRepetitions: maxNullable(
group.points.map((point) => point.bestRepetitions),
),
longestDurationSeconds: maxNullable(
group.points.map((point) => point.longestDurationSeconds),
),
totalWorkingVolume: group.points.reduce(
(total, point) => total + point.workingVolume,
0,
),
averageRpe: rpeCount ? rpeTotal / rpeCount : null,
rpeCount,
trendMetric,
latest: metricPoints[0] ?? group.points[0],
trend: metricPoints.slice(0, 8).reverse(),
completedExecutions: summary.completedExecutions,
bestWeight: summary.bestWeight,
repetitionsAtBestWeight: summary.repetitionsAtBestWeight,
bestRepetitions: summary.bestRepetitions,
longestDurationSeconds: summary.longestDurationSeconds,
totalWorkingVolume: summary.totalWorkingVolume,
averageRpe: summary.averageRpe,
rpeCount: summary.rpeCount,
trendMetric: summary.trendMetric,
latest: summary.metricPoints[0] ?? group.points[0],
trend: summary.metricPoints.slice(0, 8).reverse(),
};
})
.sort((left, right) => {
Expand Down
12 changes: 12 additions & 0 deletions migrations/0002_mcp_read_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE mcp_read_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_hint TEXT NOT NULL,
created_at INTEGER NOT NULL,
revoked_at INTEGER
);

CREATE INDEX mcp_read_tokens_user_idx
ON mcp_read_tokens(user_id, revoked_at, created_at DESC);
137 changes: 137 additions & 0 deletions tests/history-analytics-performance.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { performance } from "node:perf_hooks";
import test from "node:test";
import { createServer } from "vite";

const SIZES = [50, 250, 500];
const ITERATIONS = 25;
const EXPECTED_HASHES = new Map([
[50, "4a1278846a049e8abaab37035ef5dcf1f817a22fea8512d5b5926b6a855dc9b8"],
[250, "bc7296bb5223078b7eb1810b1822ef1bc343b4a20524794c1f23e10f62726579"],
[500, "d23d83d5588606a97912239ad1b189e522a1eb70ad3feef96cdd7057889d8a38"],
]);

let deriveHistoryAnalytics;
let vite;

test.before(async () => {
vite = await createServer({
appType: "custom",
configFile: false,
server: { middlewareMode: true },
});
({ deriveHistoryAnalytics } = await vite.ssrLoadModule(
"/app/lib/history-analytics.ts",
));
});

test.after(async () => {
await vite.close();
});

test("history analytics scales across the supported session limit", () => {
const metrics = [];

for (const size of SIZES) {
const history = buildHistory(size);
const expected = JSON.stringify(deriveHistoryAnalytics(history));
const expectedHash = createHash("sha256").update(expected).digest("hex");
assert.equal(expectedHash, EXPECTED_HASHES.get(size));
let durationMs = 0;

for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
const startedAt = performance.now();
const result = deriveHistoryAnalytics(history);
durationMs += performance.now() - startedAt;
const serialized = JSON.stringify(result);
assert.equal(serialized, expected);
assert.equal(
createHash("sha256").update(serialized).digest("hex"),
expectedHash,
);
}

metrics.push(`size${size}=${(durationMs / ITERATIONS).toFixed(3)}ms/op`);
}

console.log(`[benchmark] ${metrics.join(" ")} (${ITERATIONS} iterations)`);
console.log(`[resource] maximum_supported_sessions=${SIZES.at(-1)}`);
});

function buildHistory(size) {
return Array.from({ length: size }, (_, historyIndex) => {
const executions = Array.from({ length: 18 }, (_, executionIndex) => {
const exerciseIndex = executionIndex % 12;
const weight = 40 + exerciseIndex * 2.5 + (historyIndex % 8) * 1.25;
const reps = 5 + ((historyIndex + executionIndex) % 8);
return {
id: `execution-${historyIndex}-${executionIndex}`,
source: "planned",
clonedFromId: null,
plannedPosition: executionIndex + 1,
performedPosition: executionIndex + 1,
deferred: false,
status: executionIndex % 17 === 0 ? "skipped" : "completed",
step: {
id: `step-${executionIndex}`,
plannedStepId: `step-${executionIndex}`,
exercise: `Exercise ${exerciseIndex}`,
setType: executionIndex % 6 === 0 ? "Warm-up" : "Working",
setLabel: `Set ${executionIndex + 1}`,
tracking: "weight-reps",
targetWeight: weight,
targetReps: reps,
targetRepsMax: reps + 2,
targetDurationSeconds: null,
restSeconds: 90,
targetRpe: 8,
cue: "",
optional: false,
},
segments: [
{
id: `segment-${historyIndex}-${executionIndex}`,
weight,
reps,
durationSeconds: null,
},
],
actualRpe: 6 + ((historyIndex + executionIndex) % 4),
startedAt: historyIndex * 100_000 + executionIndex * 1_000,
completedAt: historyIndex * 100_000 + executionIndex * 1_000 + 500,
authoredRestSeconds: 90,
adjustedRestSeconds: 90,
actualRestSeconds: 80 + (executionIndex % 20),
};
});

return {
id: `history-${historyIndex}`,
workoutId: historyIndex % 10 === 0 ? "custom:conditioning" : "upper",
workoutName: historyIndex % 10 === 0 ? "Conditioning" : "Upper",
weekNumber: (historyIndex % 12) + 1,
completedAt: 2_000_000_000_000 - historyIndex * 86_400_000,
durationSeconds: 2_400 + (historyIndex % 600),
completedSets: executions.filter((record) => record.status === "completed").length,
modifiedSets: historyIndex % 3,
extraSets: historyIndex % 2,
deferredSets: historyIndex % 4,
skippedSets: executions.filter((record) => record.status === "skipped").length,
workingVolume: executions.reduce(
(total, record) =>
record.status === "completed" && record.step.setType === "Working"
? total + record.segments[0].weight * record.segments[0].reps
: total,
0,
),
warmupVolume: 0,
completedDurationSeconds: 0,
totalActualRestSeconds: 1_400,
averageRpe: 7.5,
quality: 4,
detailsAvailable: true,
executions,
};
});
}
Loading
Loading