Skip to content

Commit 58177f3

Browse files
jandroavclaude
andcommitted
replace General with custom distance + fix 10K VDOT
Replace the "General training" distance card with a "Custom" card that lets the user enter any race distance (1–200 km/mi). Custom distances route to the closest predefined plan template: ≤ 15.5 km → 10k template ≤ 31.5 km → half-marathon template > 31.5 km → marathon template The unit toggle propagates to the custom-distance input — entering "15" in km mode then toggling to mi shows "9.25". Typing in the custom field auto-selects the Custom radio. While in there, switch VDOT computation from table lookup to Daniels' canonical formula. The previous TEN_K_VDOT table I'd added during the audit had wrong values (35:11 indexed as VDOT 50 when it's actually VDOT 60); the formula corrects this and also makes custom distances consistent with predefined ones. Half-marathon and marathon VDOT values are unchanged. Pace tables (PACE_S/M/U/I/R) remain as VDOT-indexed lookups since those are the methodology deliverable. I18N: replace `0`/`general`-keyed entries with `custom`; add `labelCustomDistance` and `errCustom` strings in EN+ES. Tests: 9 new tests covering computeVDOT, closestTemplate, and the custom-distance plan flow. Updated VDOT-lookup tests for the corrected 10k values. 142 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f56e47f commit 58177f3

5 files changed

Lines changed: 297 additions & 120 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
vtrain is a single-page static site that generates 12-week running training plans following Jack Daniels' VDOT methodology. Hosted on GitHub Pages at `vtrain.jandroav.net`. The UI is bilingual (English default, Spanish toggle) and supports both km and miles via a unit toggle. UI copy lives in `I18N`. Workouts in `PLAN_CONFIG` are stored in km + Spanish Daniels notation and converted at render time: `translateWorkout` for ES→EN, `applyUnitToWorkout`/`paceKmToMi`/`applyUnitToKmTotal` for km→mi (track meter reps stay metric — track is universal). Distance precision: continuous values round to 0.25 mi, weekly totals round to whole miles.
88

9+
VDOT is computed via Daniels' canonical formula in `computeVDOT(distanceKm, raceTimeSeconds)` — no race-time lookup tables. This gives consistent values for any race distance (predefined or custom). Custom distances route to the closest predefined plan template (10/21/42 km) via `closestTemplate(distanceKm)`.
10+
911
## Files
1012

1113
- **`index.html`** — form (distance / race date / goal time) and output container. Inline CSS, dark theme.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# vtrain
22

3-
12-week training plan generator following [Jack Daniels'](https://en.wikipedia.org/wiki/Jack_Daniels_(coach)) VDOT methodology — for 10k, half-marathon, marathon, and general training.
3+
12-week training plan generator following [Jack Daniels'](https://en.wikipedia.org/wiki/Jack_Daniels_(coach)) VDOT methodology — for 10k, half-marathon, marathon, or any custom race distance.
44

55
🔗 **[vtrain.jandroav.net](https://vtrain.jandroav.net)**
66

77
## Features
88

9+
- **Any race distance**: pick from the four predefined distances (marathon, half, 10k) or enter a custom distance — vtrain routes custom distances to the closest predefined plan template
910
- **Daniels methodology**, 12 weeks across the four phases:
1011
- **Phase I — Foundation** (s1–s3): easy running + strides
1112
- **Phase II — Early Quality** (s4–s6): repetition (R) work

index.html

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,52 @@
238238
opacity: 0.85;
239239
}
240240

241+
.custom-distance-row {
242+
margin-top: 0.5rem;
243+
}
244+
.custom-distance-row[hidden] { display: none; }
245+
.custom-distance-input {
246+
position: relative;
247+
display: flex;
248+
align-items: stretch;
249+
}
250+
.custom-distance-input input {
251+
width: 100%;
252+
padding: 0.7rem 3.25rem 0.7rem 0.8rem;
253+
font-family: inherit;
254+
font-size: 0.95rem;
255+
background: var(--bg-input);
256+
border: 1px solid var(--border);
257+
border-radius: var(--radius);
258+
color: var(--fg);
259+
transition: border-color 0.15s, box-shadow 0.15s;
260+
color-scheme: dark;
261+
-moz-appearance: textfield;
262+
}
263+
.custom-distance-input input::-webkit-outer-spin-button,
264+
.custom-distance-input input::-webkit-inner-spin-button {
265+
-webkit-appearance: none;
266+
margin: 0;
267+
}
268+
.custom-distance-input input:hover { border-color: var(--border-strong); }
269+
.custom-distance-input input:focus {
270+
outline: none;
271+
border-color: var(--accent);
272+
box-shadow: 0 0 0 3px var(--accent-soft);
273+
}
274+
.custom-distance-unit {
275+
position: absolute;
276+
right: 0.85rem;
277+
top: 50%;
278+
transform: translateY(-50%);
279+
font-size: 0.78rem;
280+
font-weight: 600;
281+
letter-spacing: 0.05em;
282+
text-transform: uppercase;
283+
color: var(--fg-muted);
284+
pointer-events: none;
285+
}
286+
241287
.form-row {
242288
display: grid;
243289
grid-template-columns: 1fr 1fr;
@@ -814,10 +860,19 @@ <h1 class="hero-title" data-i18n="heroTitle"></h1>
814860
<span class="dist-num">10</span>
815861
<span class="dist-label" data-distance-label="10"></span>
816862
</label>
817-
<label class="distance-card">
818-
<input type="radio" name="distancia" value="0">
863+
<label class="distance-card distance-card--custom">
864+
<input type="radio" name="distancia" value="custom">
819865
<span class="dist-num"></span>
820-
<span class="dist-label" data-distance-label="0"></span>
866+
<span class="dist-label" data-distance-label="custom"></span>
867+
</label>
868+
</div>
869+
<div class="custom-distance-row" hidden>
870+
<label class="field">
871+
<span class="field-label" data-i18n="labelCustomDistance"></span>
872+
<span class="custom-distance-input">
873+
<input type="number" id="customDistance" min="0.5" max="200" step="0.25" placeholder="">
874+
<span class="custom-distance-unit">km</span>
875+
</span>
821876
</label>
822877
</div>
823878
</div>

tests/vtrain.test.mjs

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ before(() => {
1616
globalThis.document = {
1717
documentElement: { lang: "en" },
1818
title: "",
19-
getElementById: () => ({ innerHTML: "" }),
19+
getElementById: () => ({ innerHTML: "", value: "", focus: () => {} }),
20+
querySelector: () => null,
2021
querySelectorAll: () => [],
2122
addEventListener: () => {},
2223
createElement: () => ({ click: () => {} }),
@@ -33,9 +34,9 @@ before(() => {
3334
Object.assign(globalThis, {
3435
PLAN_CONFIG, I18N, DEFAULT_GOAL_TIMES, DISTANCE_NUMS,
3536
MI_PER_KM, KM_PER_MI,
36-
HALF_MARATHON_VDOT, MARATHON_VDOT, TEN_K_VDOT,
3737
PACE_S, PACE_M, PACE_U, PACE_I, PACE_R,
38-
parseTimeToSeconds, getVDOT, calculatePaces, selectWeek,
38+
parseTimeToSeconds, getVDOT, computeVDOT, closestTemplate,
39+
calculatePaces, selectWeek,
3940
dayKey, parseLocalDate, formatDate, addDays,
4041
translateWorkout, buildPlan, buildICS, renderPlan,
4142
kmToMi, paceKmToMi, applyUnitToWorkout, applyUnitToKmTotal, fmtNum,
@@ -48,29 +49,87 @@ before(() => {
4849

4950
// ---------- VDOT lookup ----------
5051

51-
describe("VDOT lookup", () => {
52+
describe("VDOT computation (Daniels' formula)", () => {
53+
// VDOT comes from a closed-form formula now, validated against published
54+
// Daniels reference values for the canonical race distances.
5255
test("marathon 02:28:00 → VDOT 67", () => {
5356
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("02:28:00"), 42), 67);
5457
});
55-
test("marathon 03:10:49 → VDOT 50 (exact match)", () => {
58+
test("marathon 03:10:49 → VDOT 50 (Daniels' canonical anchor)", () => {
5659
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("03:10:49"), 42), 50);
5760
});
61+
test("half-marathon 01:31:35 → VDOT 50 (Daniels' canonical anchor)", () => {
62+
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("01:31:35"), 21), 50);
63+
});
5864
test("half-marathon 01:34:53 → VDOT 48", () => {
5965
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("01:34:53"), 21), 48);
6066
});
61-
test("10k 35:11 → VDOT 50 (regression: was 70 when routed through HALF table)", () => {
62-
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("00:35:11"), 10), 50);
67+
test("10k 41:32 → VDOT 50 (Daniels' canonical 10K anchor)", () => {
68+
// Regression: previous TEN_K_VDOT table had wrong values (35:11 indexed
69+
// as VDOT 50 instead of 60). The formula corrects this.
70+
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("00:41:32"), 10), 50);
71+
});
72+
test("10k 35:11 → VDOT ~60 (formula, was wrongly 50 in table)", () => {
73+
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("00:35:11"), 10), 60);
6374
});
64-
test("general training (distance 0) routes through half-marathon table", () => {
65-
assert.equal(globalThis.getVDOT(globalThis.parseTimeToSeconds("01:34:53"), 0), 48);
75+
test("custom 15 km in 1:00:00 → reasonable VDOT (~50)", () => {
76+
const v = globalThis.getVDOT(globalThis.parseTimeToSeconds("01:00:00"), 15);
77+
assert.ok(v >= 45 && v <= 55, `expected ~50, got ${v}`);
6678
});
67-
test("unsupported distance throws", () => {
68-
assert.throws(() => globalThis.getVDOT(1000, 5));
79+
test("zero or negative distance throws", () => {
80+
assert.throws(() => globalThis.getVDOT(1000, 0));
81+
assert.throws(() => globalThis.getVDOT(1000, -5));
6982
});
70-
test("very fast marathon clamps to highest VDOT in table", () => {
71-
// 02:00:00 marathon is faster than the table's fastest entry (VDOT 70 = 02:23:10)
83+
test("very fast marathon clamps to top of pace-table range (79)", () => {
7284
const v = globalThis.getVDOT(globalThis.parseTimeToSeconds("02:00:00"), 42);
73-
assert.equal(v, 70);
85+
assert.equal(v, 79);
86+
});
87+
test("very slow marathon clamps to bottom of pace-table range (30)", () => {
88+
const v = globalThis.getVDOT(globalThis.parseTimeToSeconds("06:00:00"), 42);
89+
assert.equal(v, 30);
90+
});
91+
});
92+
93+
describe("closestTemplate", () => {
94+
test("≤ 15.5 km → 10k template", () => {
95+
assert.equal(globalThis.closestTemplate(5), 10);
96+
assert.equal(globalThis.closestTemplate(10), 10);
97+
assert.equal(globalThis.closestTemplate(15), 10);
98+
assert.equal(globalThis.closestTemplate(15.5), 10);
99+
});
100+
test("15.5 < d ≤ 31.5 km → half-marathon template", () => {
101+
assert.equal(globalThis.closestTemplate(20), 21);
102+
assert.equal(globalThis.closestTemplate(25), 21);
103+
assert.equal(globalThis.closestTemplate(31.5), 21);
104+
});
105+
test("> 31.5 km → marathon template (covers ultras too)", () => {
106+
assert.equal(globalThis.closestTemplate(32), 42);
107+
assert.equal(globalThis.closestTemplate(50), 42);
108+
assert.equal(globalThis.closestTemplate(100), 42);
109+
});
110+
});
111+
112+
describe("Custom distance plan", () => {
113+
test("buildPlan accepts non-standard distance and returns 12 weeks", () => {
114+
const plan = globalThis.buildPlan(15, "2026-12-13", "01:00:00");
115+
assert.equal(plan.weeks.length, 12);
116+
assert.equal(plan.distance, 15);
117+
});
118+
test("custom 5 km routes to 10k template content", () => {
119+
const tenK = globalThis.buildPlan(10, "2026-12-13", "00:42:00");
120+
const fiveK = globalThis.buildPlan(5, "2026-12-13", "00:20:00");
121+
// Same template = same Q sessions
122+
assert.equal(fiveK.weeks[0].schedule[2].value, tenK.weeks[0].schedule[2].value);
123+
});
124+
test("custom 30 km routes to half-marathon template content", () => {
125+
const half = globalThis.buildPlan(21, "2026-12-13", "01:35:00");
126+
const cust = globalThis.buildPlan(30, "2026-12-13", "02:30:00");
127+
assert.equal(cust.weeks[0].schedule[2].value, half.weeks[0].schedule[2].value);
128+
});
129+
test("custom 50 km (ultra) routes to marathon template content", () => {
130+
const mara = globalThis.buildPlan(42, "2026-12-13", "03:30:00");
131+
const ultr = globalThis.buildPlan(50, "2026-12-13", "04:30:00");
132+
assert.equal(ultr.weeks[0].schedule[2].value, mara.weeks[0].schedule[2].value);
74133
});
75134
});
76135

@@ -100,14 +159,7 @@ describe("Pace calculation", () => {
100159

101160
// ---------- VDOT / pace table coverage ----------
102161

103-
describe("VDOT / pace tables", () => {
104-
test("all VDOT race-time tables cover 30..70", () => {
105-
for (let v = 30; v <= 70; v++) {
106-
assert.ok(globalThis.MARATHON_VDOT[v], `MARATHON_VDOT[${v}] missing`);
107-
assert.ok(globalThis.HALF_MARATHON_VDOT[v], `HALF_MARATHON_VDOT[${v}] missing`);
108-
assert.ok(globalThis.TEN_K_VDOT[v], `TEN_K_VDOT[${v}] missing`);
109-
}
110-
});
162+
describe("Pace tables", () => {
111163
test("all pace tables cover 30..79", () => {
112164
for (let v = 30; v <= 79; v++) {
113165
assert.ok(globalThis.PACE_S[v], `PACE_S[${v}] missing`);
@@ -204,8 +256,8 @@ describe("buildPlan structure", () => {
204256
assert.ok(valid.has(w.phase), `unknown phase: ${w.phase}`);
205257
}
206258
});
207-
test("every distance produces a complete plan", () => {
208-
for (const dist of [42, 21, 10, 0]) {
259+
test("every distance produces a complete plan (predefined + custom)", () => {
260+
for (const dist of [42, 21, 10, 5, 15, 30, 50]) {
209261
const plan = globalThis.buildPlan(dist, "2026-12-13", "01:35:00");
210262
assert.equal(plan.weeks.length, 12);
211263
for (const w of plan.weeks) {
@@ -571,9 +623,9 @@ describe("Unit conversion", () => {
571623
assert.equal(globalThis.applyUnitToKmTotal("122k", "km"), "122k");
572624
});
573625

574-
test("DISTANCE_NUMS covers all 4 distances in both units", () => {
626+
test("DISTANCE_NUMS covers all 4 distance options in both units", () => {
575627
for (const u of ["km", "mi"]) {
576-
for (const d of [42, 21, 10, 0]) {
628+
for (const d of [42, 21, 10, "custom"]) {
577629
assert.ok(globalThis.DISTANCE_NUMS[u][d], `DISTANCE_NUMS[${u}][${d}] missing`);
578630
}
579631
}
@@ -588,8 +640,8 @@ describe("Unit conversion", () => {
588640
describe("Default goal times", () => {
589641
// Regression: marathon previously stayed at 01:35:00 (a half-marathon time)
590642
// when the user picked the marathon card — visually nonsensical for a 42 km race.
591-
test("every distance has a default", () => {
592-
for (const dist of [42, 21, 10, 0]) {
643+
test("every distance option has a default", () => {
644+
for (const dist of [42, 21, 10, "custom"]) {
593645
assert.ok(globalThis.DEFAULT_GOAL_TIMES[dist], `no default for ${dist}`);
594646
}
595647
});
@@ -608,11 +660,11 @@ describe("Default goal times", () => {
608660
assert.ok(s >= 30 * 60);
609661
assert.ok(s <= 75 * 60);
610662
});
611-
test("each default maps to a VDOT in the supported range (30..70)", () => {
612-
for (const dist of [42, 21, 10, 0]) {
663+
test("each default maps to a VDOT in the supported range (30..79)", () => {
664+
for (const dist of [42, 21, 10]) {
613665
const t = globalThis.DEFAULT_GOAL_TIMES[dist];
614666
const v = globalThis.getVDOT(globalThis.parseTimeToSeconds(t), dist);
615-
assert.ok(v >= 30 && v <= 70, `${dist}: ${t} → VDOT ${v} out of range`);
667+
assert.ok(v >= 30 && v <= 79, `${dist}: ${t} → VDOT ${v} out of range`);
616668
}
617669
});
618670
});

0 commit comments

Comments
 (0)