Spaces:
Sleeping
Sleeping
| <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% ; } | |
| .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> | |