Session tracking only ever stored a done boolean - no record of what weight or reps were actually performed, so there was no way to derive real progress data even though the dashboard implied "progress tracking" was a thing. Workout templates only hold a static planned value, not a history of performance. Adds actual_weight/actual_reps to workout_session_items, captured inline when checking off a set in a session (defaults to the workout's planned weight/reps, editable before confirming). The exercise detail page now shows the user's personal best (heaviest completed set, tie broken by reps) computed from their completed session history across all workouts containing that exercise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
17 KiB
PHP
242 lines
17 KiB
PHP
<x-app-layout>
|
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<a href="{{ route('workouts.show', $workout) }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← {{ __('app.sessions.back_to_workout') }}</a>
|
|
<h1 class="mt-2 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $workout->title }}</h1>
|
|
<p class="mt-1 text-gray-600 dark:text-gray-300">{{ __('app.sessions.progress') }}: {{ $doneByItemId->where('done', true)->count() }} / {{ $workout->items->count() }}</p>
|
|
</div>
|
|
<form method="POST" action="{{ route('sessions.complete', $session) }}" onsubmit="return confirm('{{ __('app.sessions.mark_complete_confirm') }}');">
|
|
@csrf
|
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-indigo-600 to-fuchsia-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-indigo-500 hover:to-fuchsia-500" title="{{ __('app.sessions.mark_complete') }}" aria-label="{{ __('app.sessions.mark_complete') }}">{{ __('app.sessions.mark_complete') }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
|
@if($workout->items->count() === 0)
|
|
<div class="mt-3 rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-sm text-gray-600 dark:text-gray-300">{{ __('app.sessions.no_items') }}</div>
|
|
@else
|
|
<ul class="mt-1 space-y-3">
|
|
@foreach($workout->items as $item)
|
|
@php $si = $doneByItemId->get($item->id); $isDone = $si && $si->done; @endphp
|
|
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4" data-session-id="{{ $session->id }}" data-item-id="{{ $item->id }}" data-rest-seconds="{{ $item->rest_seconds ?? 60 }}">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-start gap-3">
|
|
<form method="POST" action="{{ route('sessions.items.toggle', [$session, $item]) }}" class="session-toggle-form" data-item-id="{{ $item->id }}" data-initial-done="{{ $isDone ? '1' : '0' }}">
|
|
@csrf
|
|
@method('PATCH')
|
|
<input type="hidden" name="actual_weight" class="actual-weight-input" value="{{ old('actual_weight', $si->actual_weight ?? $item->weight) }}">
|
|
<input type="hidden" name="actual_reps" class="actual-reps-input" value="{{ old('actual_reps', $si->actual_reps ?? $item->reps) }}">
|
|
<button type="submit" class="h-6 w-6 rounded-md border flex items-center justify-center transition-colors duration-150"
|
|
aria-pressed="{{ $isDone ? 'true' : 'false' }}"
|
|
title="{{ $isDone ? __('app.sessions.mark_complete') : __('app.sessions.progress') }}">
|
|
<span class="sr-only">Toggle</span>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 {{ $isDone ? 'text-white' : 'text-transparent' }}">
|
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.5 7.5a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 12.086l6.793-6.793a1 1 0 011.414 0z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
<div>
|
|
<div class="exercise-title text-base font-semibold {{ $isDone ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-white' }}">{{ $item->exercise->display_name }}</div>
|
|
<div class="mt-2 flex items-center gap-3 text-sm">
|
|
<label class="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
|
|
{{ __('app.sessions.weight_lifted') }}
|
|
<input type="number" step="0.5" min="0" class="actual-weight-field w-20 rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-2 py-1" value="{{ $si->actual_weight ?? $item->weight }}">
|
|
kg
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
|
|
{{ __('app.sessions.reps_done') }}
|
|
<input type="number" min="0" class="actual-reps-field w-16 rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-2 py-1" value="{{ $si->actual_reps ?? $item->reps }}">
|
|
</label>
|
|
</div>
|
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
@if($item->sets) {{ $item->sets }} {{ __('app.sessions.sets') }} @endif
|
|
@if($item->reps) · {{ $item->reps }} {{ __('app.sessions.reps') }} @endif
|
|
@if($item->weight) · {{ $item->weight }} kg @endif
|
|
@if($item->rest_seconds) · {{ $item->rest_seconds }}s {{ __('app.sessions.rest') }} @endif
|
|
</div>
|
|
@if($item->notes)
|
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $item->notes }}</div>
|
|
@endif
|
|
|
|
<!-- Timers -->
|
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<!-- Exercise timer (count-up) -->
|
|
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-gray-700 p-3 flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs text-gray-500">{{ __('app.sessions.exercise_timer') }}</div>
|
|
<div class="exercise-timer-display text-sm font-semibold text-gray-900 dark:text-white">00:00</div>
|
|
</div>
|
|
<button type="button" class="exercise-timer-toggle inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold text-white bg-emerald-600 hover:bg-emerald-500">{{ __('app.sessions.start_timer') }}</button>
|
|
</div>
|
|
|
|
<!-- Rest timer (countdown) -->
|
|
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-gray-700 p-3 flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs text-gray-500">{{ __('app.sessions.rest_timer') }}</div>
|
|
<div class="rest-timer-display text-sm font-semibold text-gray-900 dark:text-white">00:00</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button type="button" class="rest-start inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500">{{ __('app.sessions.start_rest') }}</button>
|
|
<button type="button" class="rest-pause inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">{{ __('app.sessions.pause_rest') }}</button>
|
|
<button type="button" class="rest-reset inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700">{{ __('app.sessions.reset_rest') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@endif
|
|
</div>
|
|
</section>
|
|
|
|
@push('scripts')
|
|
<script>
|
|
(function(){
|
|
function updateBtnState(btn, done) {
|
|
btn.setAttribute('aria-pressed', done ? 'true' : 'false');
|
|
// Background/border colors
|
|
if (done) {
|
|
btn.classList.add('bg-emerald-500','border-emerald-600','text-white');
|
|
btn.classList.remove('border-gray-300','dark:border-gray-700');
|
|
} else {
|
|
btn.classList.remove('bg-emerald-500','border-emerald-600','text-white');
|
|
btn.classList.add('border-gray-300','dark:border-gray-700');
|
|
}
|
|
const icon = btn.querySelector('svg');
|
|
if (icon) {
|
|
icon.classList.toggle('text-white', !!done);
|
|
icon.classList.toggle('text-transparent', !done);
|
|
}
|
|
// Also toggle line-through for exercise name in the same item row
|
|
const row = btn.closest('li');
|
|
if (row) {
|
|
const titleEl = row.querySelector('.exercise-title');
|
|
if (titleEl) {
|
|
if (done) {
|
|
titleEl.classList.add('line-through','text-gray-500','dark:text-gray-400');
|
|
titleEl.classList.remove('text-gray-900','dark:text-white');
|
|
} else {
|
|
titleEl.classList.remove('line-through','text-gray-500','dark:text-gray-400');
|
|
titleEl.classList.add('text-gray-900','dark:text-white');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', function(e){
|
|
const btn = e.target.closest('.session-toggle-form button[type="submit"]');
|
|
if (!btn) return;
|
|
const form = btn.closest('form.session-toggle-form');
|
|
if (!form) return;
|
|
e.preventDefault();
|
|
|
|
// Optimistically toggle UI
|
|
const wasDone = btn.getAttribute('aria-pressed') === 'true';
|
|
const next = !wasDone;
|
|
updateBtnState(btn, next);
|
|
|
|
// Sync the visible weight/reps fields (outside the form) into the hidden form inputs
|
|
const row = btn.closest('li');
|
|
if (row) {
|
|
const weightField = row.querySelector('.actual-weight-field');
|
|
const repsField = row.querySelector('.actual-reps-field');
|
|
const weightInput = form.querySelector('.actual-weight-input');
|
|
const repsInput = form.querySelector('.actual-reps-input');
|
|
if (weightField && weightInput) weightInput.value = weightField.value;
|
|
if (repsField && repsInput) repsInput.value = repsField.value;
|
|
}
|
|
|
|
fetch(form.action, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': form.querySelector('input[name="_token"]').value,
|
|
'Accept': 'text/html',
|
|
},
|
|
body: new URLSearchParams(new FormData(form))
|
|
}).then(function(res){
|
|
if (!res.ok) throw new Error('Network response was not ok');
|
|
}).catch(function(){
|
|
// Revert on error
|
|
updateBtnState(btn, wasDone);
|
|
alert('Failed to update. Please try again.');
|
|
});
|
|
});
|
|
|
|
// --- Timers ---
|
|
function pad(n){ return n < 10 ? '0'+n : ''+n; }
|
|
function fmt(ms){ ms = Math.max(0, ms|0); const s = Math.floor(ms/1000); const m = Math.floor(s/60); const rs = s%60; return pad(m)+':'+pad(rs); }
|
|
|
|
function storageGet(key){ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch(e){ return null; } }
|
|
function storageSet(key, val){ try { localStorage.setItem(key, JSON.stringify(val)); } catch(e){} }
|
|
|
|
const intervals = new Map(); // key -> interval id
|
|
|
|
function setupExerciseTimer(row){
|
|
const sessionId = row.getAttribute('data-session-id');
|
|
const itemId = row.getAttribute('data-item-id');
|
|
const key = `sess:${sessionId}:item:${itemId}:ex`;
|
|
const display = row.querySelector('.exercise-timer-display');
|
|
const toggle = row.querySelector('.exercise-timer-toggle');
|
|
if (!display || !toggle) return;
|
|
|
|
let state = storageGet(key) || { running:false, elapsedMs:0, startedAt: null };
|
|
|
|
function render(){ display.textContent = fmt(state.running ? (Date.now() - (state.startedAt||Date.now()) + (state.elapsedMs||0)) : (state.elapsedMs||0)); toggle.textContent = state.running ? (toggle.getAttribute('data-stop-label') || '{{ __('app.sessions.stop_timer') }}') : (toggle.getAttribute('data-start-label') || '{{ __('app.sessions.start_timer') }}'); }
|
|
|
|
function start(){ if (state.running) return; state.running = true; state.startedAt = Date.now(); storageSet(key, state); render(); const iid = setInterval(render, 500); intervals.set(key, iid); }
|
|
function stop(){ if (!state.running) return; state.elapsedMs = (Date.now() - (state.startedAt||Date.now())) + (state.elapsedMs||0); state.running = false; state.startedAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } }
|
|
|
|
toggle.addEventListener('click', function(){ state.running ? stop() : start(); });
|
|
|
|
// resume
|
|
if (state.running && state.startedAt){ const iid = setInterval(render, 500); intervals.set(key, iid); }
|
|
render();
|
|
}
|
|
|
|
function setupRestTimer(row){
|
|
const sessionId = row.getAttribute('data-session-id');
|
|
const itemId = row.getAttribute('data-item-id');
|
|
const defSeconds = parseInt(row.getAttribute('data-rest-seconds')||'60', 10);
|
|
const key = `sess:${sessionId}:item:${itemId}:rest`;
|
|
const display = row.querySelector('.rest-timer-display');
|
|
const btnStart = row.querySelector('.rest-start');
|
|
const btnPause = row.querySelector('.rest-pause');
|
|
const btnReset = row.querySelector('.rest-reset');
|
|
if (!display || !btnStart || !btnPause || !btnReset) return;
|
|
|
|
let state = storageGet(key) || { running:false, remainingMs: defSeconds*1000, targetAt: null, defaultMs: defSeconds*1000 };
|
|
|
|
function render(){ const ms = state.running ? Math.max(0, (state.targetAt||Date.now()) - Date.now()) : (state.remainingMs||0); display.textContent = fmt(ms); if (ms <= 0 && state.running){ // auto-stop when finished
|
|
state.running = false; state.remainingMs = 0; state.targetAt = null; storageSet(key, state); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } try{ display.classList.add('text-emerald-600'); setTimeout(()=>display.classList.remove('text-emerald-600'), 2000);}catch(e){}
|
|
} }
|
|
function start(){ if (state.running) return; const ms = (state.remainingMs==null ? state.defaultMs : state.remainingMs); state.running = true; state.targetAt = Date.now() + Math.max(0, ms); storageSet(key, state); render(); const iid = setInterval(render, 500); intervals.set(key, iid); }
|
|
function pause(){ if (!state.running) return; state.remainingMs = Math.max(0, (state.targetAt||Date.now()) - Date.now()); state.running = false; state.targetAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } }
|
|
function reset(){ state.running = false; state.remainingMs = state.defaultMs; state.targetAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } }
|
|
|
|
btnStart.addEventListener('click', start);
|
|
btnPause.addEventListener('click', pause);
|
|
btnReset.addEventListener('click', reset);
|
|
|
|
// resume
|
|
if (state.running && state.targetAt){ const iid = setInterval(render, 500); intervals.set(key, iid); }
|
|
render();
|
|
}
|
|
|
|
document.querySelectorAll('li[data-session-id][data-item-id]').forEach(function(row){
|
|
setupExerciseTimer(row);
|
|
setupRestTimer(row);
|
|
});
|
|
})();
|
|
</script>
|
|
@endpush
|
|
</x-app-layout>
|