workout-tracker / static /index.html
ronheichman's picture
Deploy workout tracker
eaf1220 verified
Raw
History Blame Contribute Delete
40.8 kB
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#131e29">
<meta name="color-scheme" content="dark">
<title>Tracker</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<style>
/* Mobile: full-width layout, safe areas, touch targets, no input-zoom on iOS */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
padding-bottom: env(safe-area-inset-bottom, 0);
}
main.tracker-root {
max-width: 100%;
box-sizing: border-box;
padding-left: max(0.75rem, env(safe-area-inset-left, 0px));
padding-right: max(0.75rem, env(safe-area-inset-right, 0px));
padding-top: max(0.5rem, env(safe-area-inset-top, 0px));
}
main.tracker-root section {
margin-bottom: 1.25rem;
}
canvas { max-height: 120px; width: 100% !important; }
.tab-bar { display: flex; gap: 0; margin-bottom: 1rem; }
.tab-bar button { flex: 1; border-radius: 0; margin: 0; }
.tab-bar button:first-child { border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius); }
.tab-bar button:last-child { border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0; }
.tab-bar button.outline { background: transparent; }
.tab-bar button {
min-height: 3rem;
padding-top: 0.65rem;
padding-bottom: 0.65rem;
touch-action: manipulation;
}
.progress-bar { display: flex; gap: 4px; margin-bottom: 1rem; }
.progress-dot { flex: 1; height: 6px; border-radius: 3px; background: var(--pico-muted-border-color); }
.progress-dot.done { background: var(--pico-primary); }
.progress-dot.active { background: var(--pico-primary); opacity: 0.6; }
.group-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;
opacity: 0.5; margin-bottom: 0.25rem; }
.superset-badge { display: inline-block; font-size: 0.7rem; padding: 2px 8px;
border-radius: 12px; background: var(--pico-primary-background); color: var(--pico-primary);
margin-bottom: 0.5rem; font-weight: 600; }
.exercise-card { margin-bottom: 1rem; padding: 0.85rem 0.75rem; border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius); overflow-wrap: anywhere; word-break: break-word; }
.exercise-card header { margin-bottom: 0.5rem; padding: 0; background: none; }
.exercise-card header strong { font-size: 1rem; line-height: 1.3; display: block; }
.exercise-card header small { display: block; opacity: 0.6; font-size: 0.8rem; line-height: 1.35; }
.round-label { font-size: 0.8rem; font-weight: 600; opacity: 0.7; margin: 0.75rem 0 0.25rem; }
.set-input-row { display: flex; gap: 0.5rem; align-items: stretch; margin-bottom: 0.5rem; flex-wrap: nowrap; }
.set-input-row label { font-size: 0.8rem; min-width: 2rem; opacity: 0.6; margin-bottom: 0; align-self: center; flex-shrink: 0; }
.set-input-row input[type="number"] {
margin-bottom: 0; text-align: center; min-height: 3rem; font-size: 1rem;
width: 100%; min-width: 0; box-sizing: border-box;
}
.set-input-row .weight-input { flex: 2; min-width: 0; }
.set-input-row .reps-input { flex: 1; min-width: 0; max-width: 6.5rem; }
.set-input-row.set-input-row-reps-only .reps-input { max-width: none; flex: 1; }
.suggestion-line { font-size: 0.8rem; opacity: 0.6; margin-bottom: 0.5rem; }
.suggestion-line strong { opacity: 1; }
.suggestion-note { font-size: 0.78rem; opacity: 0.85; margin-bottom: 0.5rem; line-height: 1.35; }
.suggestion-ref { font-size: 0.7rem; opacity: 0.5; margin: 0 0 0.5rem; line-height: 1.3; }
.day-summary-pre { white-space: pre-wrap; font-size: 0.78rem; line-height: 1.4; opacity: 0.88;
margin: 0 0 1rem; padding: 0.65rem 0.75rem; border-radius: var(--pico-border-radius);
border: 1px solid var(--pico-muted-border-color); overflow-x: auto; -webkit-overflow-scrolling: touch; }
.calib-err-msg { font-size: 0.8rem; color: #e57373; margin: 0 0 0.5rem; }
.rest-between-rounds { margin: 1rem 0 1.25rem; padding: 0.75rem 0; border-top: 1px solid var(--pico-muted-border-color);
min-height: 3rem; }
.rest-between-rounds .rest-hint { font-size: 0.75rem; opacity: 0.55; margin-bottom: 0.5rem; }
.rest-timer { text-align: center; padding: 1rem 0.5rem; border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color); border: 1px solid var(--pico-muted-border-color); }
.rest-timer .timer-value { font-size: 2.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.2; }
.rest-timer .timer-label { font-size: 0.8rem; opacity: 0.6; margin-bottom: 0.35rem; }
.rest-timer .timer-actions { margin-top: 0.65rem; display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
.rest-between-rounds > button.outline {
width: 100%;
min-height: 3rem;
white-space: normal;
line-height: 1.25;
padding: 0.65rem 0.75rem;
touch-action: manipulation;
}
.nav-buttons { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
@media (min-width: 540px) {
.nav-buttons { flex-direction: row; flex-wrap: wrap; }
}
.nav-buttons button {
flex: 1 1 auto;
min-height: 3rem;
touch-action: manipulation;
}
.nav-buttons .calib-err-msg { flex: 1 1 100%; order: -1; }
.success-banner { background: var(--pico-primary); color: var(--pico-primary-inverse);
padding: 0.75rem 1rem; border-radius: var(--pico-border-radius); text-align: center;
margin-bottom: 1rem; font-weight: 600; }
.last-day-note { font-size: 0.8rem; opacity: 0.6; }
.rest-day { text-align: center; padding: 2rem 0; opacity: 0.6; }
.history-item {
display: grid;
grid-template-columns: 1fr;
gap: 0.2rem 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
font-size: 0.88rem;
}
@media (min-width: 480px) {
.history-item {
grid-template-columns: auto 1fr auto;
align-items: baseline;
justify-content: space-between;
}
}
.history-item span { min-width: 0; overflow-wrap: anywhere; }
.bw-form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
@media (min-width: 400px) {
.bw-form-grid { grid-template-columns: 1fr auto; align-items: end; }
}
.run-form-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.run-form-stack input,
.run-form-stack textarea {
margin-bottom: 0;
min-height: 3rem;
font-size: 1rem;
}
.run-form-stack textarea { min-height: 4.5rem; padding: 0.65rem 0.75rem; }
.run-notes-span { opacity: 0.75; font-size: 0.82rem; }
.run-workout-block { margin-bottom: 1.25rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--pico-muted-border-color); }
.run-empty-hint { padding: 0.5rem 0; }
@media (max-width: 539px) {
main.tracker-root input[type="number"],
main.tracker-root input[type="text"],
main.tracker-root textarea,
main.tracker-root select {
font-size: 1rem;
}
}
main.tracker-root select {
width: 100%;
min-height: 3rem;
}
main.tracker-root h2 { font-size: clamp(1.25rem, 5vw, 1.75rem); }
main.tracker-root h3 { font-size: clamp(1.05rem, 4vw, 1.35rem); }
.app-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.app-header hgroup { margin-bottom: 0; }
.logout-form { margin: 0; align-self: center; }
.logout-form button { margin: 0; min-height: 2.5rem; }
</style>
</head>
<body>
<main class="container tracker-root" x-data="tracker()" x-init="init()">
<header class="app-header">
<hgroup>
<h2>Tracker</h2>
<p x-text="todayStr"></p>
</hgroup>
<form method="post" action="/logout" class="logout-form">
<button type="submit" class="outline">Log out</button>
</form>
</header>
<div class="tab-bar">
<button :class="tab === 'workout' ? '' : 'outline'" @click="tab='workout'">Workout</button>
<button :class="tab === 'history' ? '' : 'outline'" @click="tab='history'; loadHistory()">History</button>
</div>
<!-- Workout tab -->
<div x-show="tab === 'workout'">
<!-- Body weight section -->
<section>
<h3>Body Weight</h3>
<form @submit.prevent="logWeight()">
<div class="bw-form-grid">
<input type="number" inputmode="decimal" step="0.1"
:min="useLb ? 66 : 30" :max="useLb ? 450 : 200"
x-model.number="bodyWeightField"
:placeholder="useLb ? 'lb' : 'kg'" required autocomplete="off">
<button type="submit" :disabled="weightLoading"
x-text="weightLoading ? 'Saving...' : 'Log'"></button>
</div>
<p class="calib-err-msg" x-show="weightLogError" x-text="weightLogError"></p>
</form>
<canvas id="weightChart" height="100"></canvas>
</section>
<!-- Post-submit success -->
<section x-show="submitted">
<div class="success-banner" x-text="submitMsg"></div>
<button class="outline" @click="resetWorkout()">Log Another Workout</button>
</section>
<!-- Day selector: stays visible during a session so you can switch A/B without losing the control -->
<section x-show="!submitted">
<p x-show="!workout" class="rest-day">Select a program day to start your workout.</p>
<p x-show="lastWorkoutDay && !workout" class="last-day-note" x-text="'Last workout: Day ' + lastWorkoutDay + ' on ' + lastWorkoutDate"></p>
<div class="grid">
<select x-model="selectedDay" @change="loadWorkout()">
<option value="">Select day...</option>
<template x-for="dl in dayLabels" :key="dl">
<option :value="dl" x-text="'Day ' + dl"></option>
</template>
</select>
</div>
</section>
<!-- Active program day: run (always) + strength groups when present -->
<section x-show="workout && !submitted">
<hgroup>
<h3 x-text="workout ? workout.program_name : ''"></h3>
<p>Day <span x-text="workout ? workout.day_label : ''"></span></p>
</hgroup>
<pre class="day-summary-pre" x-show="workout && workout.day_summary_text"
x-text="workout.day_summary_text"></pre>
<section class="run-workout-block">
<h3>Running</h3>
<p class="last-day-note">Distance and duration apply to this program day (same as your lifts below).</p>
<form @submit.prevent="logRunForWorkout()">
<div class="run-form-stack">
<label for="rw-dist" x-text="useMi ? 'Distance (mi)' : 'Distance (km)'"></label>
<input id="rw-dist" type="number" inputmode="decimal" step="0.01" min="0.01"
x-model.number="runDistanceField"
:placeholder="useMi ? 'mi' : 'km'" required autocomplete="off">
<label for="rw-dur">Duration (min)</label>
<input id="rw-dur" type="number" inputmode="decimal" step="0.5" min="0.5"
x-model.number="runDurationMin" placeholder="min" required autocomplete="off">
<label for="rw-notes">Notes (optional)</label>
<textarea id="rw-notes" rows="2" x-model="runNotes" placeholder="Route, surface, how it felt…"></textarea>
</div>
<button type="submit" :disabled="runLoading"
x-text="runLoading ? 'Saving...' : 'Log run'"></button>
<p class="calib-err-msg" x-show="runError" x-text="runError"></p>
</form>
<p class="group-label">Runs for this day</p>
<p x-show="runsForDay.length === 0" class="rest-day run-empty-hint">None yet.</p>
<template x-for="r in runsForDay" :key="r.run_log_id">
<div class="history-item">
<span x-text="formatShortDate(r.logged_at)"></span>
<span x-text="formatRunSummary(r)"></span>
<span x-show="r.notes" class="run-notes-span" x-text="r.notes"></span>
</div>
</template>
</section>
<p x-show="workout.exercises.length === 0" class="rest-day">No strength exercises for this day. Log a run above or pick another day.</p>
<section x-show="workout.exercises.length > 0 && activeGroup">
<!-- Progress dots -->
<div class="progress-bar">
<template x-for="(g, gi) in groups" :key="gi">
<div class="progress-dot"
:class="gi < currentGroup ? 'done' : (gi === currentGroup ? 'active' : '')"></div>
</template>
</div>
<!-- Current group -->
<div>
<p class="group-label" x-text="'Group ' + (currentGroup + 1) + ' of ' + groups.length"></p>
<span class="superset-badge" x-show="activeGroup && activeGroup.exercises.length > 1">SUPERSET</span>
<!-- Rounds -->
<template x-for="round in activeRounds" :key="round">
<div>
<p class="round-label" x-text="'Round ' + round + ' of ' + activeRounds.length"></p>
<template x-for="ex in activeGroup ? activeGroup.exercises : []" :key="ex.exercise_id + '-r' + round">
<div class="exercise-card" x-show="round <= ex.target_sets">
<header>
<strong x-text="ex.name"></strong>
<small x-text="ex.muscle_group + ' · ' + ex.equipment"></small>
</header>
<p class="suggestion-line"
x-show="ex.weight_increment_kg !== 0 && ex.suggestion.weight_kg !== null">
<strong x-text="formatStrengthWeight(ex.suggestion.weight_kg)"></strong>
· <span x-text="(ex.suggestion.target_reps || 0) + ' reps target'"></span>
</p>
<p class="suggestion-line" x-show="ex.weight_increment_kg === 0">
<strong x-text="(ex.suggestion.target_reps || 0) + ' reps target'"></strong>
</p>
<p class="suggestion-note" x-show="ex.suggestion.weight_kg === null"
x-text="ex.suggestion.note"></p>
<p class="suggestion-ref" x-show="ex.suggestion.load_reference"
x-text="ex.suggestion.load_reference"></p>
<p class="suggestion-line"
x-show="ex.suggestion.weight_kg === null && ex.suggestion.note && ex.suggestion.note.startsWith('No prior log')"
x-text="'Rep range ' + ex.target_rep_min + '–' + ex.target_rep_max"></p>
<div class="set-input-row"
:class="{ 'set-input-row-reps-only': ex.weight_increment_kg === 0 }">
<label x-show="ex.weight_increment_kg !== 0" x-text="useLb ? 'lb' : 'kg'"></label>
<input type="number" inputmode="decimal" :step="useLb ? 1 : 0.5" min="0"
class="weight-input"
x-show="ex.weight_increment_kg !== 0"
:value="formatWeightInputValue(getSetData(ex.exercise_id, round - 1).weight_kg)"
@input="setWeightFromInput(ex.exercise_id, round - 1, $event.target.value)">
<label>reps</label>
<input type="number" inputmode="numeric" min="0" max="99"
class="reps-input"
:value="getSetData(ex.exercise_id, round - 1).reps_done"
@input="setReps(ex.exercise_id, round - 1, parseInt($event.target.value))">
</div>
</div>
</template>
<!-- Rest after this round (before next round): supersets and singles use same flow -->
<div class="rest-between-rounds" x-show="round < activeRounds.length">
<p class="rest-hint" x-text="activeGroup && activeGroup.exercises.length > 1
? 'After all moves in this round, rest before the next round.'
: 'Rest before the next set.'"></p>
<div class="rest-timer" x-show="restActive && restAfterRound === round">
<div class="timer-label">REST</div>
<div class="timer-value" x-text="restDisplay"></div>
<div class="timer-actions">
<button class="outline" type="button" @click="skipRest()">Skip</button>
</div>
</div>
<button class="outline" type="button"
x-show="!restActive"
@click="startRestAfterRound(round)"
x-text="'Start ' + activeRestSeconds + 's rest (before round ' + (round + 1) + ')'">
</button>
</div>
</div>
</template>
</div>
<!-- Navigation -->
<div class="nav-buttons">
<p class="calib-err-msg" x-show="submitError" x-text="submitError"></p>
<button class="outline" @click="prevGroup()" :disabled="currentGroup === 0">Back</button>
<button x-show="currentGroup < groups.length - 1" @click="nextGroup()">Next Group</button>
<button x-show="currentGroup === groups.length - 1"
@click="submitWorkout()" :disabled="submitLoading"
x-text="submitLoading ? 'Saving...' : 'Submit Workout'">
</button>
</div>
</section>
</section>
</div>
<!-- History tab -->
<div x-show="tab === 'history'">
<section>
<h3>Weight Trend</h3>
<canvas id="historyWeightChart" height="120"></canvas>
</section>
<section>
<h3>Recent Workouts</h3>
<p x-show="historyWorkouts.length === 0" class="rest-day">No workouts logged yet.</p>
<template x-for="ws in historyWorkouts" :key="ws.logged_at">
<div class="history-item">
<span x-text="new Date(ws.logged_at).toLocaleDateString('en-US', {month: 'short', day: 'numeric'})"></span>
<span x-text="ws.program_name + ' · Day ' + ws.day_label"></span>
<span x-text="ws.exercise_count + ' ex, ' + ws.total_sets + ' sets'"></span>
</div>
</template>
</section>
<section>
<h3>Runs</h3>
<p x-show="historyRuns.length === 0" class="rest-day">No runs logged yet.</p>
<template x-for="r in historyRuns" :key="r.run_log_id">
<div class="history-item">
<span x-text="formatShortDate(r.logged_at)"></span>
<span x-text="runHistoryLine(r)"></span>
<span x-show="r.notes" class="run-notes-span" x-text="r.notes"></span>
</div>
</template>
</section>
</div>
</main>
<script>
const TRACKER_KG_PER_LB = 0.45359237;
const TRACKER_KM_PER_MI = 1.609344;
function apiFetch(url, options) {
const merged = options ? { credentials: 'include', ...options } : { credentials: 'include' };
return fetch(url, merged);
}
function tracker() {
return {
todayStr: new Date().toLocaleDateString('en-US', {weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'}),
tab: 'workout',
useLb: false,
useMi: false,
bodyWeightField: null,
weightLoading: false,
weightLogError: '',
weightChart: null,
historyChart: null,
workout: null,
selectedDay: '',
dayLabels: [],
lastWorkoutDay: null,
lastWorkoutDate: null,
programId: null,
submitted: false,
submitLoading: false,
submitMsg: '',
submitError: '',
historyWorkouts: [],
historyRuns: [],
runDistanceField: null,
runDurationMin: null,
runNotes: '',
runLoading: false,
runError: '',
runsForDay: [],
groups: [],
currentGroup: 0,
activeGroup: null,
activeRounds: [],
activeRestSeconds: 90,
setData: {},
restActive: false,
restAfterRound: null,
restSeconds: 0,
restDisplay: '',
restInterval: null,
formatStrengthWeight(kg) {
if (kg == null || Number.isNaN(kg)) {
return '—';
}
if (this.useLb) {
const lb = Math.round((kg / TRACKER_KG_PER_LB) * 10) / 10;
return `${lb} lb`;
}
return `${kg} kg`;
},
formatWeightInputValue(kg) {
if (kg == null || Number.isNaN(kg)) {
return '';
}
if (!this.useLb) {
return kg;
}
return Math.round((kg / TRACKER_KG_PER_LB) * 10) / 10;
},
async init() {
const [hist, prog, disp] = await Promise.all([
apiFetch('/api/history?days=30').then(r => r.json()),
apiFetch('/api/programs/active').then(r => r.json()),
apiFetch('/api/meta/display').then(r => r.ok ? r.json() : { weight_unit: 'lb', distance_unit: 'mi' }),
]);
this.useLb = disp.weight_unit === 'lb';
this.useMi = disp.distance_unit === 'mi';
if (hist.body_weights.length > 0) {
const last = hist.body_weights[hist.body_weights.length - 1];
this.bodyWeightField = this.useLb
? Math.round((last.weight_kg / TRACKER_KG_PER_LB) * 10) / 10
: last.weight_kg;
}
this.$nextTick(() => this.renderWeightChart(hist.body_weights));
this.dayLabels = prog.day_labels;
this.programId = prog.program_id;
this.lastWorkoutDay = prog.last_workout_day;
this.lastWorkoutDate = prog.last_workout_date;
},
async logWeight() {
this.weightLogError = '';
this.weightLoading = true;
try {
const wkg = this.useLb ? this.bodyWeightField * TRACKER_KG_PER_LB : this.bodyWeightField;
const resp = await apiFetch('/api/weight', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({weight_kg: wkg}),
});
if (!resp.ok) {
const detail = await resp.text();
this.weightLogError = detail || `Could not save weight (${resp.status}).`;
return;
}
const histResp = await apiFetch('/api/history?days=30');
if (!histResp.ok) {
this.weightLogError = 'Saved, but could not refresh the chart.';
return;
}
const hist = await histResp.json();
const weights = Array.isArray(hist.body_weights) ? hist.body_weights : [];
this.renderWeightChart(weights);
} catch (err) {
this.weightLogError = err instanceof Error ? err.message : 'Network error while logging weight.';
} finally {
this.weightLoading = false;
}
},
async loadWorkout() {
if (!this.selectedDay) {
this.workout = null;
this.activeGroup = null;
this.groups = [];
this.runsForDay = [];
return;
}
this.skipRest();
const resp = await apiFetch(`/api/today?day_label=${this.selectedDay}`);
if (!resp.ok) { this.workout = null; this.runsForDay = []; return; }
this.workout = await resp.json();
this.currentGroup = 0;
this.setData = {};
this.buildGroups();
this.syncActiveGroup();
await this.loadRunsForDay();
},
buildGroups() {
const exercises = this.workout.exercises;
const groupMap = new Map();
let standaloneCounter = -1;
for (const ex of exercises) {
let key = ex.superset_group;
if (key === null || key === undefined) {
key = standaloneCounter;
standaloneCounter--;
}
if (!groupMap.has(key)) groupMap.set(key, []);
groupMap.get(key).push(ex);
}
this.groups = [...groupMap.values()].map(exercises => ({ exercises }));
for (const ex of exercises) {
this.setData[ex.exercise_id] = [];
for (let s = 0; s < ex.target_sets; s++) {
this.setData[ex.exercise_id].push({
weight_kg: ex.suggestion.weight_kg,
reps_done: ex.suggestion.target_reps,
});
}
}
},
syncActiveGroup() {
const g = this.groups[this.currentGroup];
if (g) {
this.activeGroup = g;
const maxSets = Math.max(...g.exercises.map(e => e.target_sets));
this.activeRounds = Array.from({length: maxSets}, (_, i) => i + 1);
this.activeRestSeconds = Math.max(...g.exercises.map(e => e.rest_seconds));
} else {
this.activeGroup = null;
this.activeRounds = [];
this.activeRestSeconds = 90;
}
},
getSetData(exerciseId, setIdx) {
const sets = this.setData[exerciseId];
if (!sets || !sets[setIdx]) return { weight_kg: null, reps_done: 0 };
return sets[setIdx];
},
setWeightFromInput(exerciseId, setIdx, rawStr) {
const parsed = parseFloat(rawStr);
if (Number.isNaN(parsed)) {
return;
}
const kg = this.useLb ? parsed * TRACKER_KG_PER_LB : parsed;
if (!this.setData[exerciseId]) {
return;
}
this.setData[exerciseId][setIdx].weight_kg = kg;
if (setIdx === 0) {
for (let i = 1; i < this.setData[exerciseId].length; i++) {
this.setData[exerciseId][i].weight_kg = kg;
}
}
},
setReps(exerciseId, setIdx, value) {
if (!this.setData[exerciseId]) return;
this.setData[exerciseId][setIdx].reps_done = value;
},
startRestAfterRound(completedRound) {
if (this.restInterval) {
clearInterval(this.restInterval);
this.restInterval = null;
}
this.restSeconds = this.activeRestSeconds;
this.restAfterRound = completedRound;
this.restActive = true;
this.restDisplay = this.formatTime(this.restSeconds);
this.restInterval = setInterval(() => {
this.restSeconds -= 1;
this.restDisplay = this.formatTime(this.restSeconds);
if (this.restSeconds <= 0) {
this.skipRest();
}
}, 1000);
},
skipRest() {
this.restActive = false;
this.restAfterRound = null;
if (this.restInterval) {
clearInterval(this.restInterval);
this.restInterval = null;
}
},
formatTime(secs) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}s`;
},
prevGroup() {
this.skipRest();
if (this.currentGroup > 0) {
this.currentGroup--;
this.syncActiveGroup();
}
},
nextGroup() {
this.skipRest();
if (this.currentGroup < this.groups.length - 1) {
this.currentGroup++;
this.syncActiveGroup();
}
},
async submitWorkout() {
this.submitError = '';
this.submitLoading = true;
const allSets = [];
for (const ex of this.workout.exercises) {
const exSets = this.setData[ex.exercise_id] || [];
exSets.forEach((s, si) => {
allSets.push({
exercise_id: ex.exercise_id,
set_number: si + 1,
reps_done: s.reps_done || 0,
weight_kg: s.weight_kg || 0,
});
});
}
const resp = await apiFetch('/api/log', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
program_id: this.workout.program_id,
day_label: this.workout.day_label,
sets: allSets,
}),
});
this.submitLoading = false;
if (!resp.ok) {
const detail = await resp.text();
this.submitError = detail || `Could not save workout (${resp.status}).`;
return;
}
const result = await resp.json();
this.submitMsg = `Workout logged: ${result.logged} sets for Day ${this.workout.day_label}`;
this.submitted = true;
this.lastWorkoutDay = this.workout.day_label;
this.lastWorkoutDate = new Date().toISOString().slice(0, 10);
},
resetWorkout() {
this.workout = null;
this.submitted = false;
this.submitMsg = '';
this.submitError = '';
this.selectedDay = '';
this.groups = [];
this.activeGroup = null;
this.activeRounds = [];
this.setData = {};
this.currentGroup = 0;
this.runsForDay = [];
this.skipRest();
},
formatShortDate(isoStr) {
return new Date(isoStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
},
formatRunSummary(r) {
const dur = r.duration_minutes;
if (this.useMi) {
const mi = r.distance_km / TRACKER_KM_PER_MI;
const pace = mi > 0 ? (dur / mi).toFixed(1) : '';
const pacePart = pace ? ` · ${pace} min/mi` : '';
return `${mi.toFixed(2)} mi · ${dur} min${pacePart}`;
}
const pace = r.distance_km > 0 ? (dur / r.distance_km).toFixed(1) : '';
const pacePart = pace ? ` · ${pace} min/km` : '';
return `${r.distance_km} km · ${dur} min${pacePart}`;
},
runHistoryLine(r) {
const name = r.program_name || 'Unknown';
const day = r.day_label != null && r.day_label !== '' ? r.day_label : '?';
return `${name} · Day ${day} · ${this.formatRunSummary(r)}`;
},
async loadRunsForDay() {
if (!this.workout) {
this.runsForDay = [];
return;
}
const pid = this.workout.program_id;
const dl = encodeURIComponent(this.workout.day_label);
const resp = await apiFetch(`/api/runs?days=365&program_id=${pid}&day_label=${dl}`);
if (!resp.ok) {
this.runsForDay = [];
return;
}
this.runsForDay = await resp.json();
},
async logRunForWorkout() {
if (!this.workout) return;
this.runError = '';
this.runLoading = true;
try {
const distKm = this.useMi ? this.runDistanceField * TRACKER_KM_PER_MI : this.runDistanceField;
const resp = await apiFetch('/api/log-run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
program_id: this.workout.program_id,
day_label: this.workout.day_label,
distance_km: distKm,
duration_minutes: this.runDurationMin,
notes: this.runNotes || '',
}),
});
if (!resp.ok) {
const detail = await resp.text();
this.runError = detail || `Could not save run (${resp.status}).`;
return;
}
this.runDistanceField = null;
this.runDurationMin = null;
this.runNotes = '';
await this.loadRunsForDay();
} catch (err) {
this.runError = err instanceof Error ? err.message : 'Network error while logging run.';
} finally {
this.runLoading = false;
}
},
async loadHistory() {
const hist = await (await apiFetch('/api/history?days=90')).json();
this.historyWorkouts = hist.workout_sessions.reverse();
this.historyRuns = Array.isArray(hist.runs) ? hist.runs : [];
this.$nextTick(() => {
const canvas = document.getElementById('historyWeightChart');
if (!canvas) return;
if (this.historyChart) this.historyChart.destroy();
this.historyChart = new Chart(canvas, {
type: 'line',
data: {
labels: hist.body_weights.map(w => new Date(w.logged_at).toLocaleDateString('en-US', {month: 'short', day: 'numeric'})),
datasets: [{
label: this.useLb ? 'lb' : 'kg',
data: hist.body_weights.map(w => this.useLb ? w.weight_kg / TRACKER_KG_PER_LB : w.weight_kg),
borderWidth: 2, tension: 0.3, pointRadius: 3,
}],
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: false } } },
});
});
},
renderWeightChart(weights) {
const ctx = document.getElementById('weightChart');
if (!ctx) return;
if (this.weightChart) this.weightChart.destroy();
this.weightChart = new Chart(ctx, {
type: 'line',
data: {
labels: weights.map(w => new Date(w.logged_at).toLocaleDateString('en-US', {month: 'short', day: 'numeric'})),
datasets: [{
label: this.useLb ? 'lb' : 'kg',
data: weights.map(w => this.useLb ? w.weight_kg / TRACKER_KG_PER_LB : w.weight_kg),
borderWidth: 2, tension: 0.3, pointRadius: 3,
}],
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: false } } },
});
},
};
}
</script>
</body>
</html>