Skip to content

Commit 6c801bd

Browse files
Ensure workout logs remain sorted per template (#36)
- add a mobile-friendly workout logger that loads templates, pre-fills from the last session, autosaves drafts, and submits completed workouts - document the new logger tool - extend the Cloudflare Worker with workout log endpoints and ensure per-template logs are stored in date order for easy latest lookup ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_692356cd01f08325b909e099a435a53a)
1 parent acab082 commit 6c801bd

3 files changed

Lines changed: 778 additions & 7 deletions

File tree

cloudflare-workers/workouts/worker.js

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const FRONTEND_ORIGIN = "https://tools.mathspp.com";
22
const WORKOUTS_KEY = "user:me:workouts";
3+
const WORKOUT_LOGS_PREFIX = "user:me:workoutLogs:";
34

45
function corsHeaders() {
56
return {
@@ -51,6 +52,17 @@ async function saveWorkouts(env, workouts) {
5152
await env.WORKOUTS.put(WORKOUTS_KEY, JSON.stringify(workouts));
5253
}
5354

55+
async function getWorkoutLogs(env, workoutId) {
56+
const logs = await env.WORKOUTS.get(`${WORKOUT_LOGS_PREFIX}${workoutId}`, "json");
57+
const parsed = Array.isArray(logs) ? logs : [];
58+
return parsed.sort((a, b) => new Date(b.finishedAt) - new Date(a.finishedAt));
59+
}
60+
61+
async function saveWorkoutLogs(env, workoutId, logs) {
62+
const sorted = [...logs].sort((a, b) => new Date(b.finishedAt) - new Date(a.finishedAt));
63+
await env.WORKOUTS.put(`${WORKOUT_LOGS_PREFIX}${workoutId}`, JSON.stringify(sorted));
64+
}
65+
5466
function normalizeExerciseBlock(block) {
5567
if (!block || typeof block !== "object") {
5668
return null;
@@ -92,9 +104,70 @@ function findWorkout(workouts, id) {
92104
return workouts.find((workout) => workout.id === id);
93105
}
94106

107+
function normalizeSet(set, setIndex) {
108+
const weight = Number.isFinite(Number(set?.weight)) ? Number(set.weight) : null;
109+
const reps = Number.isFinite(Number(set?.reps)) ? Number(set.reps) : null;
110+
const rir = Number.isFinite(Number(set?.rir)) ? Number(set.rir) : null;
111+
const notes = typeof set?.notes === "string" ? set.notes.trim() : "";
112+
113+
return {
114+
setIndex,
115+
weight,
116+
reps,
117+
rir,
118+
notes,
119+
};
120+
}
121+
122+
function normalizeExerciseLog(block, payloadExercise) {
123+
const sets = [];
124+
const providedSets = Array.isArray(payloadExercise?.sets) ? payloadExercise.sets : [];
125+
126+
for (let i = 0; i < block.sets; i += 1) {
127+
sets.push(normalizeSet(providedSets[i] || {}, i));
128+
}
129+
130+
const progressionNotes =
131+
typeof payloadExercise?.progressionNotes === "string" ? payloadExercise.progressionNotes.trim() : "";
132+
133+
return {
134+
blockId: block.id,
135+
blockName: block.name,
136+
sets,
137+
progressionNotes,
138+
};
139+
}
140+
141+
function buildLogEntry(template, payload) {
142+
const startedAt = typeof payload?.startedAt === "string" ? payload.startedAt : null;
143+
const finishedAt = typeof payload?.finishedAt === "string" ? payload.finishedAt : null;
144+
const overallNotes = typeof payload?.overallNotes === "string" ? payload.overallNotes.trim() : "";
145+
146+
const payloadExercises = Array.isArray(payload?.exercises) ? payload.exercises : [];
147+
const exerciseMap = new Map(payloadExercises.map((ex) => [ex?.blockId, ex]));
148+
149+
const exercises = template.exerciseBlocks.map((block) => {
150+
const payloadExercise = exerciseMap.get(block.id) || {};
151+
return normalizeExerciseLog(block, payloadExercise);
152+
});
153+
154+
const completedAt = finishedAt || new Date().toISOString();
155+
156+
return {
157+
id: crypto.randomUUID(),
158+
workoutTemplateId: template.id,
159+
templateName: template.name,
160+
startedAt: startedAt || completedAt,
161+
finishedAt: completedAt,
162+
overallNotes,
163+
exercises,
164+
};
165+
}
166+
95167
export default {
96168
async fetch(request, env) {
97169
const url = new URL(request.url);
170+
const pathParts = url.pathname.replace(/^\/+/, "").split("/");
98171

99172
if (request.method === "OPTIONS") {
100173
return handleOptions();
@@ -145,20 +218,41 @@ export default {
145218
return jsonResponse(workout, 201);
146219
}
147220

148-
if (url.pathname.startsWith("/api/workouts/")) {
149-
const id = decodeURIComponent(url.pathname.replace("/api/workouts/", ""));
150-
151-
if (!id) {
152-
return jsonResponse({ error: "Workout id is required" }, 400);
153-
}
154-
221+
if (pathParts[0] === "api" && pathParts[1] === "workouts" && pathParts[2]) {
222+
const id = decodeURIComponent(pathParts[2]);
155223
const workouts = await getWorkouts(env);
156224
const existing = findWorkout(workouts, id);
157225

158226
if (!existing) {
159227
return jsonResponse({ error: "Workout not found" }, 404);
160228
}
161229

230+
if (pathParts[3] === "logs") {
231+
if (request.method === "GET" && pathParts[4] === "latest") {
232+
const logs = await getWorkoutLogs(env, id);
233+
const latest = logs[0] || null;
234+
return jsonResponse({ log: latest });
235+
}
236+
237+
if (request.method === "GET") {
238+
const logs = await getWorkoutLogs(env, id);
239+
return jsonResponse({ logs });
240+
}
241+
242+
if (request.method === "POST") {
243+
const body = await readJson(request);
244+
if (body.error) {
245+
return jsonResponse({ error: body.error }, 400);
246+
}
247+
248+
const logEntry = buildLogEntry(existing, body);
249+
const logs = await getWorkoutLogs(env, id);
250+
logs.push(logEntry);
251+
await saveWorkoutLogs(env, id, logs);
252+
return jsonResponse(logEntry, 201);
253+
}
254+
}
255+
162256
if (request.method === "GET") {
163257
return jsonResponse(existing);
164258
}

workout.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Log workouts from existing templates served by the Cloudflare Worker. Choose a template, load the previous session for guidance, auto-save your sets and notes locally while you lift, and submit the finished workout back to the API when you are done.

0 commit comments

Comments
 (0)