Public
Like
routine-stack
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data β all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v202View latest version
The CLI already has a sophisticated randomization system:
exercises/ (310+ JSON files)
β catalogued by type, muscles, equipment, tags, challenge
workouts/*.json (workout templates)
β sets with criteria-based exercise selection
β set.randomize = true β shuffle exercise order
β set.poolSize β limit candidate pool
src/cli/generator.ts
β matchesCriteria() - matches exercises to criteria
β selectExercise() - picks random from matching pool
β generateWorkout() - resolves criteria β actual exercises
CLI commands:
deno task wod:gen --workout=random-4-barre
deno task wod:random --routine=barre --freeze --save=my-barre
The web UI currently:
- Loads pre-generated static JSON (frozen workouts)
- No way to re-randomize in the browser
- Doesn't use the criteria-based template system
Since generator.ts has no Deno-specific APIs, we can port it to run in the browser.
Static Files (served by Deno):
βββ /exercises/index.json β NEW: All exercises in one file
βββ /workouts/**/*.json β Existing templates with criteria
βββ /progressions/*.json β Optional: progression chains
Browser (Alpine.js):
βββ loadExercisesCatalogue() β Fetch exercises index
βββ generateWorkout() β Ported from generator.ts
βββ shuffleSet(setId) β Re-run generator for specific set
βββ shuffleWorkout() β Re-run entire workout
Create a single JSON file with all exercises for browser loading.
New file: /static/exercises.json
# Build script to concatenate all exercises into one file deno task build:exercises
// scripts/build-exercises.ts
import { walk } from "@std/fs";
import { exerciseSchema } from "../src/schemas.ts";
const exercises = [];
for await (const entry of walk("./exercises", { exts: [".json"] })) {
const data = JSON.parse(await Deno.readTextFile(entry.path));
exercises.push(exerciseSchema.parse(data));
}
await Deno.writeTextFile("./static/exercises.json", JSON.stringify(exercises));
Create a browser-compatible version of the generator.
New file: /static/generator.js
// Ported from src/cli/generator.ts - no Deno APIs
function matchesCriteria(exercise, criteria) {
if (criteria.exerciseId) {
return exercise.id === criteria.exerciseId;
}
if (criteria.types?.length > 0) {
if (!criteria.types.includes(exercise.type)) return false;
}
if (criteria.muscles?.length > 0) {
if (!criteria.muscles.some(m => exercise.muscles.includes(m))) return false;
}
if (criteria.tags?.length > 0) {
if (!criteria.tags.some(t => exercise.tags.includes(t))) return false;
}
if (criteria.equipment?.length > 0) {
if (!criteria.equipment.some(e => exercise.equipment.includes(e))) return false;
}
if (criteria.excludeTags?.length > 0) {
if (criteria.excludeTags.some(t => exercise.tags.includes(t))) return false;
}
if (criteria.challengeId) {
if (!exercise.challenge || exercise.challenge.id !== criteria.challengeId) return false;
}
return true;
}
function selectExercise(exercises, criteria, used, poolSize) {
let candidates = exercises.filter(e => matchesCriteria(e, criteria));
if (poolSize && candidates.length > poolSize) {
candidates = shuffle(candidates).slice(0, poolSize);
}
const unused = candidates.filter(e => !used.has(e.id));
if (unused.length > 0) {
const selected = unused[Math.floor(Math.random() * unused.length)];
used.add(selected.id);
return selected;
}
if (candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
return null;
}
function shuffle(array) {
return [...array].sort(() => Math.random() - 0.5);
}
function generateWorkout(workout, exercises) {
const sets = workout.sets.map(set => {
if (set.type !== 'exercises' || !set.exercises) {
return set;
}
const used = new Set();
const generatedExercises = (set.randomize ? shuffle(set.exercises) : set.exercises)
.map(se => {
const exercise = selectExercise(exercises, se.criteria, used, set.poolSize);
if (exercise) {
return {
id: exercise.id,
name: exercise.name,
reps: se.reps,
duration: se.duration,
notes: se.notes,
description: exercise.description,
challengeDay: exercise.challenge?.day,
};
}
return { id: 'unknown', name: `[No match]`, ...se };
});
return { ...set, generatedExercises };
});
return { ...workout, sets };
}
export { generateWorkout, matchesCriteria, selectExercise };
Add the generator and shuffle functionality.
Updates to index.html:
function routineStackApp() {
return {
// ... existing state ...
exercisesCatalogue: [], // NEW: loaded exercises
async init() {
// ... existing init ...
// Load exercises catalogue for randomization
await this.loadExercisesCatalogue();
},
async loadExercisesCatalogue() {
try {
const res = await fetch('./static/exercises.json');
if (res.ok) {
this.exercisesCatalogue = await res.json();
}
} catch (e) {
console.warn('Could not load exercises catalogue:', e);
}
},
// Check if a set can be randomized
isRandomizable(set) {
return set.type === 'exercises' &&
set.exercises?.some(e => !e.criteria?.exerciseId);
},
// Shuffle a specific set
shuffleSet(setId) {
if (!this.selectedWorkout || !this.exercisesCatalogue.length) return;
const setIndex = this.selectedWorkout.sets.findIndex(s => s.id === setId);
if (setIndex === -1) return;
const set = this.selectedWorkout.sets[setIndex];
const generated = generateSetExercises(
set.exercises,
this.exercisesCatalogue,
true, // randomize
set.poolSize
);
// Update the set with new exercises
this.selectedWorkout.sets[setIndex] = {
...set,
generatedExercises: generated,
};
},
// Shuffle entire workout
shuffleWorkout() {
if (!this.selectedWorkout || !this.exercisesCatalogue.length) return;
this.selectedWorkout = generateWorkout(
this.selectedWorkout,
this.exercisesCatalogue
);
},
};
}
Add shuffle buttons to randomizable sets and workouts.
<!-- Workout header with shuffle button --> <div class="workout-header"> <h2 x-text="selectedWorkout.name"></h2> <template x-if="hasRandomizableSets()"> <button class="shuffle-btn" @click="shuffleWorkout()"> <span class="iconify" data-icon="lucide:shuffle"></span> <span>Shuffle All</span> </button> </template> </div> <!-- Set header with shuffle button --> <template x-for="set in selectedWorkout.sets"> <div class="workout-set"> <div class="set-header"> <h3 x-text="set.name || 'Exercises'"></h3> <template x-if="isRandomizable(set)"> <button class="shuffle-btn-sm" @click="shuffleSet(set.id)"> <span class="iconify" data-icon="lucide:refresh-cw"></span> </button> </template> </div> <!-- Exercise list --> <template x-for="ex in (set.generatedExercises || set.exercises)"> <div class="exercise-item"> <span x-text="ex.name"></span> <template x-if="ex.challengeDay"> <span class="badge" x-text="'Day ' + ex.challengeDay"></span> </template> </div> </template> </div> </template>
Update existing workout to use criteria-based selection:
{ "id": "random-4-barre", "name": "Random 4 Barre", "description": "4 random exercises from the 100-Reps Challenge", "sets": [ { "id": "random-selection", "name": "Today's Selection", "type": "exercises", "randomize": true, "poolSize": 30, "exercises": [ { "criteria": { "challengeId": "100-reps-barre-leg-challenge" }, "reps": 100 }, { "criteria": { "challengeId": "100-reps-barre-leg-challenge" }, "reps": 100 }, { "criteria": { "challengeId": "100-reps-barre-leg-challenge" }, "reps": 100 }, { "criteria": { "challengeId": "100-reps-barre-leg-challenge" }, "reps": 100 } ] } ] }
When generated, this becomes:
{ "generatedExercises": [ { "id": "barre-squat-jump", "name": "Squat Jump", "reps": 100, "challengeDay": 26 }, { "id": "barre-donkey-kick", "name": "Donkey Kick", "reps": 100, "challengeDay": 17 }, { "id": "barre-bridge-single-leg", "name": "Single Leg Bridge", "reps": 100, "challengeDay": 14 }, { "id": "barre-fire-hydrant", "name": "Fire Hydrant", "reps": 100, "challengeDay": 12 } ] }
static/exercises.json- Concatenated exercises cataloguestatic/generator.js- Browser-compatible generatorscripts/build-exercises.ts- Build script for exercises index
deno.json- Addbuild:exercisestaskindex.html- Add generator, shuffle logic, shuffle buttonsstyles/app.scss- Add shuffle button styles
workouts/barre/random-4-barre.json- Convert to criteria-based template- Add more randomizable workout templates
- Uses existing system - No new schema, just ports CLI to browser
- Progressive enhancement - Static workouts still work, randomization is optional
- Consistent - Same logic CLI and browser
- Extensible - Easy to add more criteria filters
