Skip to content

Commit aaf0b7d

Browse files
Add workout manager tool and worker backend (#26)
## Summary - add a Workout Manager frontend for listing, viewing, creating, and deleting workout templates against the Worker API - implement the workouts Cloudflare Worker with KV-backed CRUD endpoints, CORS, and token auth - extend the deployment workflow to ship the new worker ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_691f374947d8832581460121d84ca552)
1 parent f9b6954 commit aaf0b7d

5 files changed

Lines changed: 804 additions & 0 deletions

File tree

.github/workflows/deploy-cloudflare-workers.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
pull-requests: read
1717
outputs:
1818
social_link_preview: ${{ steps.filter.outputs.social-link-preview }}
19+
workouts: ${{ steps.filter.outputs.workouts }}
1920
# If I add more filters below, I need to “export them” here.
2021
steps:
2122
- uses: actions/checkout@v4
@@ -27,6 +28,8 @@ jobs:
2728
filters: |
2829
social-link-preview:
2930
- 'cloudflare-workers/social-link-preview/**'
31+
workouts:
32+
- 'cloudflare-workers/workouts/**'
3033
# I can add more filters here and access them separately.
3134

3235
deploy-social-link-preview:
@@ -53,3 +56,28 @@ jobs:
5356
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5457
run: |
5558
wrangler deploy --env production
59+
60+
deploy-workouts:
61+
name: Deploy Workouts Worker
62+
runs-on: ubuntu-latest
63+
needs: changes
64+
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.workouts == 'true' }}
65+
steps:
66+
- name: Checkout code
67+
uses: actions/checkout@v4
68+
69+
- name: Setup Node.js
70+
uses: actions/setup-node@v4
71+
with:
72+
node-version: '20'
73+
74+
- name: Install Wrangler
75+
run: npm install -g wrangler
76+
77+
- name: Deploy to Cloudflare Workers
78+
working-directory: cloudflare-workers/workouts
79+
env:
80+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
81+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
82+
run: |
83+
wrangler deploy --env production
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
const FRONTEND_ORIGIN = "https://tools.mathspp.com";
2+
const WORKOUTS_KEY = "user:me:workouts";
3+
4+
function corsHeaders() {
5+
return {
6+
"Access-Control-Allow-Origin": FRONTEND_ORIGIN,
7+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
8+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
9+
"Vary": "Origin",
10+
};
11+
}
12+
13+
function jsonResponse(body, status = 200) {
14+
return new Response(JSON.stringify(body), {
15+
status,
16+
headers: {
17+
"Content-Type": "application/json; charset=utf-8",
18+
...corsHeaders(),
19+
},
20+
});
21+
}
22+
23+
function unauthorizedResponse() {
24+
return jsonResponse({ error: "Unauthorized" }, 401);
25+
}
26+
27+
function isAuthorized(request, env) {
28+
const auth = request.headers.get("Authorization") || "";
29+
const expected = `Bearer ${env.WORKOUT_API_TOKEN}`;
30+
return auth === expected;
31+
}
32+
33+
function handleOptions() {
34+
return new Response(null, { status: 204, headers: corsHeaders() });
35+
}
36+
37+
async function readJson(request) {
38+
try {
39+
return await request.json();
40+
} catch (error) {
41+
return { error: "Invalid JSON body" };
42+
}
43+
}
44+
45+
async function getWorkouts(env) {
46+
const workouts = await env.WORKOUTS.get(WORKOUTS_KEY, "json");
47+
return Array.isArray(workouts) ? workouts : [];
48+
}
49+
50+
async function saveWorkouts(env, workouts) {
51+
await env.WORKOUTS.put(WORKOUTS_KEY, JSON.stringify(workouts));
52+
}
53+
54+
function normalizeExerciseBlock(block) {
55+
if (!block || typeof block !== "object") {
56+
return null;
57+
}
58+
59+
const name = typeof block.name === "string" ? block.name.trim() : "";
60+
const sets = Number.isFinite(Number(block.sets)) ? Number(block.sets) : 0;
61+
const repRange = block.repRange || {};
62+
const min = Number.isFinite(Number(repRange.min)) ? Number(repRange.min) : 0;
63+
const max = Number.isFinite(Number(repRange.max)) ? Number(repRange.max) : 0;
64+
const notes = typeof block.notes === "string" ? block.notes : "";
65+
66+
if (!name) {
67+
return null;
68+
}
69+
70+
return {
71+
id: block.id || crypto.randomUUID(),
72+
name,
73+
sets: sets < 0 ? 0 : Math.round(sets),
74+
repRange: {
75+
min,
76+
max,
77+
},
78+
notes,
79+
};
80+
}
81+
82+
function updateTimestamps(existing) {
83+
const now = new Date().toISOString();
84+
if (!existing.createdAt) {
85+
existing.createdAt = now;
86+
}
87+
existing.updatedAt = now;
88+
return existing;
89+
}
90+
91+
function findWorkout(workouts, id) {
92+
return workouts.find((workout) => workout.id === id);
93+
}
94+
95+
export default {
96+
async fetch(request, env) {
97+
const url = new URL(request.url);
98+
99+
if (request.method === "OPTIONS") {
100+
return handleOptions();
101+
}
102+
103+
if (!isAuthorized(request, env)) {
104+
return unauthorizedResponse();
105+
}
106+
107+
if (url.pathname === "/api/workouts" && request.method === "GET") {
108+
const workouts = await getWorkouts(env);
109+
return jsonResponse(workouts);
110+
}
111+
112+
if (url.pathname === "/api/workouts" && request.method === "POST") {
113+
const body = await readJson(request);
114+
if (body.error) {
115+
return jsonResponse({ error: body.error }, 400);
116+
}
117+
118+
const name = typeof body.name === "string" ? body.name.trim() : "";
119+
if (!name) {
120+
return jsonResponse({ error: "Workout name is required" }, 400);
121+
}
122+
123+
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
124+
? body.exerciseBlocks
125+
: [];
126+
const normalizedBlocks = exerciseBlocksInput
127+
.map(normalizeExerciseBlock)
128+
.filter(Boolean);
129+
130+
if (normalizedBlocks.length === 0) {
131+
return jsonResponse({ error: "At least one exercise block is required" }, 400);
132+
}
133+
134+
const workouts = await getWorkouts(env);
135+
const workout = updateTimestamps({
136+
id: body.id || crypto.randomUUID(),
137+
name,
138+
exerciseBlocks: normalizedBlocks,
139+
createdAt: undefined,
140+
updatedAt: undefined,
141+
});
142+
143+
workouts.push(workout);
144+
await saveWorkouts(env, workouts);
145+
return jsonResponse(workout, 201);
146+
}
147+
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+
155+
const workouts = await getWorkouts(env);
156+
const existing = findWorkout(workouts, id);
157+
158+
if (!existing) {
159+
return jsonResponse({ error: "Workout not found" }, 404);
160+
}
161+
162+
if (request.method === "GET") {
163+
return jsonResponse(existing);
164+
}
165+
166+
if (request.method === "DELETE") {
167+
const updated = workouts.filter((workout) => workout.id !== id);
168+
await saveWorkouts(env, updated);
169+
return jsonResponse({ success: true });
170+
}
171+
172+
if (request.method === "PUT") {
173+
const body = await readJson(request);
174+
if (body.error) {
175+
return jsonResponse({ error: body.error }, 400);
176+
}
177+
178+
const name = typeof body.name === "string" ? body.name.trim() : existing.name;
179+
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
180+
? body.exerciseBlocks
181+
: existing.exerciseBlocks;
182+
const normalizedBlocks = exerciseBlocksInput
183+
.map(normalizeExerciseBlock)
184+
.filter(Boolean);
185+
186+
if (normalizedBlocks.length === 0) {
187+
return jsonResponse({ error: "At least one exercise block is required" }, 400);
188+
}
189+
190+
existing.name = name;
191+
existing.exerciseBlocks = normalizedBlocks;
192+
updateTimestamps(existing);
193+
await saveWorkouts(env, workouts);
194+
return jsonResponse(existing);
195+
}
196+
}
197+
198+
return jsonResponse({ error: "Not found" }, 404);
199+
},
200+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name = "workouts-production"
2+
main = "worker.js"
3+
compatibility_date = "2025-02-15"
4+
5+
kv_namespaces = [
6+
{ binding = "WORKOUTS", id = "replace-with-your-kv-id" }
7+
]
8+
9+
[env.production]
10+
name = "workouts-production"

workout-manager.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Manage workout templates stored in the paired Cloudflare Worker. Save your API base URL and token, list all templates, inspect a template’s exercise blocks with timing estimates, delete unwanted templates, and build new templates with reorderable exercise blocks and notes.

0 commit comments

Comments
 (0)