Skip to content
Closed
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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches:
- main
- master
- develop
pull_request:

jobs:
lint-and-test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install dependencies
run: npm install

- name: Run lint
run: npm run lint

- name: Run tests
run: npm test
70 changes: 46 additions & 24 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,57 @@
# AGENTS

## Purpose
This document orients automation and human collaborators to the structure, conventions, and workflows in this repository.
Reference guide for automation and human contributors. Use it to understand the structure, contracts, and preferred workflows before touching code.

## Quick Start
- Serve the repo with `npm run dev` (wraps `http-server`) or any static file server. Both the root `index.html` and `workout-time/index.html` expect a browser environment.
- The site ships as native ES modules. Keep files co-located and avoid bundler-only features (no JSX, CommonJS, or TypeScript transforms).
- Run automated checks with the provided npm scripts: `npm test`, `npm run lint`, `npm run format`.

## Repository Map
- `index.html` — public-facing exercise library landing page.
- `styles.css` — shared styling for the public site.
- `exercise_dump.json` — exported exercise metadata referenced by the app.
- `js/` — modular ES modules that power the exercise plan builder (`builder.js`, `context.js`, `library.js`, etc.).
- `plan-storage.js` centralises workout plan persistence. Interact with localStorage through the helpers exposed here rather than reimplementing storage access.
- `workout-time/` — standalone Vitruvian workout control UI (`index.html`, `app.js`, supporting assets).
- `local-tests/` — lightweight Node-based test harness (currently `builder.test.js`).
- `index.html`, `styles.css` — public exercise library landing page and shared styling.
- `exercise_dump.json` — canonical exercise dataset. Schema changes must stay compatible with search, filters, storage, and workout-time consumers.
- `js/` — modular frontend for the library and builder.
- `main.js` — bootstraps data loading, state wiring, and tab switching.
- `context.js` — centralised state/DOM registry; route mutations through its helpers.
- `constants.js` — shared enums and configuration (storage keys, modes, limits).
- `library.js` — filter orchestration, search invocation, card rendering.
- `search.js` — tokenisation, fuzzy scoring, and in-memory indices.
- `builder.js` — workout builder UI (drag/drop, set editing, export pipelines).
- `grouping.js` — grouping helpers reused by the builder and library views.
- `plan-storage.js` — local plan index management. Always use these helpers for persisted plans.
- `storage.js` — broader persistence (builder snapshot, plan metadata, share-link parsing).
- `utils.js`, `muscles.js` — shared helpers and canonical muscle metadata.
- `tests/` — Node test runner suites (`node --test`) covering builder flows, search scoring, storage sync, and regression cases.
- `workout-time/` — Vitruvian workout control UI.
- `app.js` — orchestrates device lifecycle, plan execution, and UI state.
- `plan-runner.js` — timeline building, rest timers, skip/rewind logic; loaded before `app.js`.
- `device.js`, `protocol.js`, `chart.js`, `modes.js`, `dropbox.js` — transport, telemetry, charting, and cloud sync support.
- `scripts/` — mock CLIs for lint/format/http-server to support sandboxed automation.
- Tooling: `package.json`, `eslint.config.js`, `prettier.config.js` define commands and formatting conventions.

## Key Flows
- The builder UI is data-driven: `js/context.js` initializes shared state; `js/builder.js` consumes that state to serialize plans. Keep mutations centralized in `context.js` helpers.
- The workout control in `workout-time/app.js` communicates with hardware over WebSocket. Update connection logic cautiously; mirror any protocol changes in both UI and device code.
- Static assets are served as-is. No bundler is configured, so prefer vanilla JS modules and relative imports.
- For storage interactions in the plan builder, rely on `plan-storage.js` helpers. They normalise plan names, manage the plan index, and guard against localStorage failures. Avoid duplicating that logic elsewhere to keep UI state and persistence consistent.
- **Data bootstrapping** — `main.js` fetches `exercise_dump.json`, normalises it through `context.js`, then requests initial renders from `library.js` and `builder.js`.
- **Builder state** — All mutations route through `context.js` helpers to keep `state.builder` and DOM references consistent. `builder.js` emits serialised payloads via `storage.js` and `plan-storage.js`.
- **Persistence** — `plan-storage.js` governs plan naming, index updates, and storage error handling. Use it instead of direct `localStorage` access.
- **Search** — `search.js` maintains token indices; `library.js` supplies filters and delegates to search for scoring. Changing tokens or weightings requires updates to both modules.
- **Workout-Time device loop** — `workout-time/app.js` manages WebSocket connections, device telemetry, and integrates `plan-runner.js` for execution. Mirror protocol changes across `protocol.js` and any firmware.

## Development Tips
- Use `npx http-server .` or similar to preview pages locally. Both the root `index.html` and `workout-time/index.html` expect to run in a browser environment.
- Run `node local-tests/builder.test.js` to sanity-check plan serialization and rebuilding logic after modifying builder modules.
- Maintain accessibility: new UI components should include keyboard support and ARIA labelling consistent with existing markup.
- After touching persistence or plan-index flows, update `plan-storage.js` first and adapt consumers (currently `js/main.js`) to avoid drift between cached plan state and saved plans.
## Development Workflow
- Add or mutate state through `context.js`. Avoid duplicating DOM queries outside of its registry.
- Maintain accessibility: mirror existing ARIA usage and keyboard interactions when extending UI.
- When touching persistence or plan serialisation, update Node tests (`tests/plan-storage.test.js`, `tests/builder-load-plan.test.js`, `tests/builder-plan-items.test.js`) to capture new expectations.
- Target evergreen browsers. If introducing less supported APIs, ship polyfills inline with usage.
- Styles live in `styles.css` and component-scoped `<style>` blocks. Reserve inline styles for dynamic states only.

## Agent Guidance
- When adding features, reflect changes in both the documentation and the relevant UI (root site vs. workout control panel) to keep experiences in sync.
- Validate data contract changes against `exercise_dump.json` to avoid breaking the plan builder.
- For styling adjustments, prefer editing `styles.css` or component-level `<style>` blocks; avoid inline styles unless scoped to dynamic states.
## Guidance for Agents
- Synchronise feature updates across the builder and workout-time surfaces; document behavioural changes in `README.md` or inline comments where appropriate.
- Validate data contract changes against `exercise_dump.json` and ensure downstream consumers still parse expected fields.
- Reuse existing utilities before introducing new helpers. Shared logic between the browser UI and workout-time app should sit in clearly named modules to prevent drift.
- Expand automated tests alongside features. Prefer Node’s built-in runner under `tests/`; use lightweight DOM stubs when browser-specific behaviour must be verified.

## Outstanding Opportunities
- Add automated linting (ESLint) and formatting to catch regressions early.
- Expand the `local-tests` suite to cover additional modules (search, storage sync, progression calculations).
- Consider extracting shared utilities between the root UI and `workout-time` into a common module to reduce duplication.
- Extend coverage for progression settings, equipment grouping, and workout-time plan execution edge cases.
- Wire lint and test scripts into CI to catch regressions before deploys.
- Extract shared plan serialisation helpers consumed by both the browser builder and workout-time runner.
- Improve telemetry logging in `workout-time/app.js` to surface connection failures and protocol mismatches.
33 changes: 29 additions & 4 deletions js/plan-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ const parsePlanItems = (raw) => {
}
};

const readPlanPayload = (name, storage = getStorage()) => {
export const getPlanStorageKey = (name) => {
const trimmed = normalizePlanName(name);
return trimmed ? `${PLAN_STORAGE_PREFIX}${trimmed}` : null;
};

export const readPlanPayload = (name, storage = getStorage()) => {
const target = getStorage(storage);
if (!target) return [];
const trimmed = normalizePlanName(name);
if (!trimmed) return [];
const raw = target.getItem(`${PLAN_STORAGE_PREFIX}${trimmed}`);
const key = getPlanStorageKey(trimmed);
const raw = key ? target.getItem(key) : null;
return parsePlanItems(raw);
};

Expand All @@ -79,7 +85,9 @@ export const persistPlanLocally = (name, items, storage = getStorage()) => {
throw new Error('Local storage is unavailable in this environment.');
}
try {
target.setItem(`${PLAN_STORAGE_PREFIX}${trimmed}`, JSON.stringify(Array.isArray(items) ? items : []));
const key = getPlanStorageKey(trimmed);
if (!key) throw new Error('Invalid plan key');
target.setItem(key, JSON.stringify(Array.isArray(items) ? items : []));
} catch (error) {
console.warn('Failed to store plan locally', error);
throw new Error('Unable to store plan in local storage.');
Expand All @@ -93,7 +101,8 @@ export const removePlanLocally = (name, storage = getStorage()) => {
const trimmed = normalizePlanName(name);
if (!trimmed || !target) return;
try {
target.removeItem(`${PLAN_STORAGE_PREFIX}${trimmed}`);
const key = getPlanStorageKey(trimmed);
if (key) target.removeItem(key);
} catch (error) {
console.warn('Failed to remove local plan', error);
}
Expand Down Expand Up @@ -126,3 +135,19 @@ export const loadLocalPlanEntries = (storage = getStorage()) => {

return entries;
};

// Expose helpers on window for non-module consumers (e.g., workout-time app)
if (typeof window !== 'undefined') {
window.PlanStorage = Object.freeze({
PLAN_INDEX_KEY,
PLAN_STORAGE_PREFIX,
normalizePlanName,
readLocalPlanIndex,
writeLocalPlanIndex,
persistPlanLocally,
removePlanLocally,
loadLocalPlanEntries,
getPlanStorageKey,
readPlanPayload
});
}
66 changes: 66 additions & 0 deletions tests/builder-plan-items.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,69 @@ test('buildPlanItems normalizes builder entries into plan items', () => {
state.builder.items = new Map(originalItems);
}
});

test('buildPlanItems clamps progression percent and respects metric units', () => {
const originalWeightUnit = state.weightUnit;
const originalOrder = [...state.builder.order];
const originalItems = new Map(state.builder.items);

try {
state.weightUnit = 'KG';
state.builder.order = ['metric-exercise'];

state.builder.items = new Map([
[
'metric-exercise',
{
exercise: {
id: 'metric-exercise',
name: 'Metric Builder'
},
sets: [
{
mode: 'PUMP',
reps: '12',
weight: '30',
progression: '1.5',
progressionPercent: '450'
},
{
mode: 'OLD_SCHOOL',
reps: '8',
weight: '28',
progression: '',
progressionPercent: '-120'
},
{
mode: 'OLD_SCHOOL',
reps: '6',
weight: '24',
progression: 'not-a-number',
progressionPercent: 'n/a'
}
]
}
]
]);

const planItems = buildPlanItems();
assert.equal(planItems.length, 3);

const [firstSet, secondSet, thirdSet] = planItems;

assert.equal(firstSet.progressionUnit, 'KG');
assert.equal(firstSet.progressionDisplay, '1.5');
assert.equal(firstSet.progressionKg, 1.5);
assert.equal(firstSet.progressionPercent, 400);

assert.equal(secondSet.progressionKg, 0);
assert.equal(secondSet.progressionPercent, -100);

assert.equal(thirdSet.progressionKg, 0);
assert.equal(thirdSet.progressionPercent, null);
} finally {
state.weightUnit = originalWeightUnit;
state.builder.order = originalOrder;
state.builder.items = originalItems;
}
});
123 changes: 123 additions & 0 deletions tests/grouping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import test, { beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';

const noop = () => {};
const stubElement = () => ({
addEventListener: noop,
appendChild: noop,
removeChild: noop,
classList: { add: noop, remove: noop, toggle: noop },
setAttribute: noop,
style: {},
innerHTML: '',
textContent: ''
});

globalThis.document = {
getElementById: stubElement,
createElement: () => stubElement(),
createDocumentFragment: () => ({ appendChild: noop }),
body: {
appendChild: noop,
removeChild: noop,
classList: { add: noop, remove: noop, toggle: noop }
}
};

globalThis.window = globalThis;
globalThis.requestAnimationFrame = (fn) => (typeof fn === 'function' ? fn() : undefined);

const { state, groupColorMap } = await import('../js/context.js');
const {
applyGrouping,
getGroupingClusters,
getGroupColor
} = await import('../js/grouping.js');

const makeExercise = (name, equipment = []) => ({
exercise: {
id: name.toLowerCase().replace(/\s+/g, '-'),
name,
equipment: Array.from(equipment)
},
sets: []
});

let snapshot = null;

const captureSnapshot = () => ({
order: [...state.builder.order],
items: new Map(state.builder.items),
flags: {
equipment: state.groupByEquipment,
muscles: state.groupByMuscles,
muscleGroups: state.groupByMuscleGroups
}
});

const restoreSnapshot = (value) => {
state.builder.order = [...value.order];
state.builder.items = new Map(value.items);
state.groupByEquipment = value.flags.equipment;
state.groupByMuscles = value.flags.muscles;
state.groupByMuscleGroups = value.flags.muscleGroups;
};

beforeEach(() => {
snapshot = captureSnapshot();
state.builder.order = [];
state.builder.items = new Map();
state.groupByEquipment = false;
state.groupByMuscles = false;
state.groupByMuscleGroups = false;
groupColorMap.clear();
});

afterEach(() => {
restoreSnapshot(snapshot);
groupColorMap.clear();
});

test('applyGrouping clusters exercises by equipment in stable order', () => {
state.builder.order = ['row', 'curl', 'squat', 'plank'];
state.builder.items = new Map([
['row', makeExercise('Row', ['Barbell'])],
['curl', makeExercise('Curl', ['Dumbbell'])],
['squat', makeExercise('Squat', ['Barbell'])],
['plank', makeExercise('Plank', [])]
]);

const changed = applyGrouping('equipment');
assert.equal(changed, true);
assert.deepEqual(state.builder.order, ['row', 'squat', 'curl', 'plank']);

const unchanged = applyGrouping('equipment');
assert.equal(unchanged, false);
});

test('getGroupingClusters returns metadata with consistent colors', () => {
state.builder.order = ['row', 'squat', 'curl', 'plank'];
state.builder.items = new Map([
['row', makeExercise('Row', ['Barbell'])],
['squat', makeExercise('Squat', ['Barbell'])],
['curl', makeExercise('Curl', ['Dumbbell'])],
['plank', makeExercise('Plank', [])]
]);

const clusters = getGroupingClusters(state.builder.order, state.builder.items, 'equipment');

assert.equal(clusters.length, 3);
assert.deepEqual(clusters.map((c) => c.ids), [
['row', 'squat'],
['curl'],
['plank']
]);

assert.equal(clusters[2].label, 'No Equipment');

clusters.forEach((cluster) => {
const color = getGroupColor('equipment', cluster.key);
assert.equal(cluster.color, color);
assert.ok(typeof color === 'string' && color.length > 0);
});
});
Loading
Loading