All checks were successful
CI / update (push) Successful in 2m0s
- Add metrics system (weight/reps/rpe/distance/duration) per exercise type so cardio exercises show distance+duration instead of weight+reps - Add 8 new cardio exercises: swimming, hiking, rowing outdoor, cycling outdoor, elliptical, stair climber, jump rope, walking - Add bilateral flag to dumbbell exercises for accurate tonnage calculation - Make SetTable, SessionCard, history detail, template editor, and exercise stats API all render/compute dynamically based on exercise metrics - Rename Profile to Stats with lifetime cards: workouts, tonnage, cardio km - Move route /fitness/profile -> /fitness/stats, API /stats/profile -> /stats/overview
514 lines
12 KiB
TypeScript
514 lines
12 KiB
TypeScript
/**
|
|
* Active workout state store — factory pattern.
|
|
* Client-side only; persisted to localStorage so state survives navigation.
|
|
* Saved to server on finish via POST /api/fitness/sessions.
|
|
*/
|
|
|
|
import { getExerciseById } from '$lib/data/exercises';
|
|
|
|
export interface WorkoutSet {
|
|
reps: number | null;
|
|
weight: number | null;
|
|
rpe: number | null;
|
|
distance: number | null;
|
|
duration: number | null;
|
|
completed: boolean;
|
|
}
|
|
|
|
export interface WorkoutExercise {
|
|
exerciseId: string;
|
|
sets: WorkoutSet[];
|
|
restTime: number; // seconds
|
|
}
|
|
|
|
export interface TemplateData {
|
|
_id: string;
|
|
name: string;
|
|
exercises: Array<{
|
|
exerciseId: string;
|
|
sets: Array<{ reps?: number; weight?: number; rpe?: number; distance?: number; duration?: number }>;
|
|
restTime?: number;
|
|
}>;
|
|
}
|
|
|
|
const STORAGE_KEY = 'fitness-active-workout';
|
|
|
|
export interface StoredState {
|
|
active: boolean;
|
|
paused: boolean;
|
|
name: string;
|
|
templateId: string | null;
|
|
exercises: WorkoutExercise[];
|
|
elapsed: number; // total elapsed seconds at time of save
|
|
savedAt: number; // Date.now() at time of save
|
|
restStartedAt: number | null; // Date.now() when rest timer started
|
|
restTotal: number; // total rest duration in seconds
|
|
}
|
|
|
|
export interface RemoteState {
|
|
name: string;
|
|
templateId: string | null;
|
|
exercises: WorkoutExercise[];
|
|
paused: boolean;
|
|
elapsed: number;
|
|
savedAt: number;
|
|
restStartedAt: number | null;
|
|
restTotal: number;
|
|
}
|
|
|
|
function createEmptySet(): WorkoutSet {
|
|
return { reps: null, weight: null, rpe: null, distance: null, duration: null, completed: false };
|
|
}
|
|
|
|
function saveToStorage(state: StoredState) {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
} catch {}
|
|
}
|
|
|
|
function loadFromStorage(): StoredState | null {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function clearStorage() {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
} catch {}
|
|
}
|
|
|
|
export function createWorkout() {
|
|
let active = $state(false);
|
|
let paused = $state(false);
|
|
let name = $state('');
|
|
let templateId: string | null = $state(null);
|
|
let exercises = $state<WorkoutExercise[]>([]);
|
|
let startTime: Date | null = $state(null);
|
|
let _pausedElapsed = $state(0); // seconds accumulated before current run
|
|
let _elapsed = $state(0);
|
|
let _restSeconds = $state(0);
|
|
let _restTotal = $state(0);
|
|
let _restActive = $state(false);
|
|
let _restStartedAt: number | null = null; // absolute timestamp
|
|
|
|
let _timerInterval: ReturnType<typeof setInterval> | null = null;
|
|
let _restInterval: ReturnType<typeof setInterval> | null = null;
|
|
let _onChangeCallback: (() => void) | null = null;
|
|
|
|
function _persist() {
|
|
if (!active) return;
|
|
// When running, compute current elapsed before saving
|
|
if (!paused && startTime) {
|
|
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
|
|
}
|
|
saveToStorage({
|
|
active,
|
|
paused,
|
|
name,
|
|
templateId,
|
|
exercises: JSON.parse(JSON.stringify(exercises)),
|
|
elapsed: _elapsed,
|
|
savedAt: Date.now(),
|
|
restStartedAt: _restActive ? _restStartedAt : null,
|
|
restTotal: _restTotal
|
|
});
|
|
_onChangeCallback?.();
|
|
}
|
|
|
|
function _computeElapsed() {
|
|
if (paused || !startTime) return;
|
|
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
|
|
}
|
|
|
|
function _startTimer() {
|
|
_stopTimer();
|
|
_timerInterval = setInterval(() => {
|
|
_computeElapsed();
|
|
}, 1000);
|
|
}
|
|
|
|
function _stopTimer() {
|
|
if (_timerInterval) {
|
|
clearInterval(_timerInterval);
|
|
_timerInterval = null;
|
|
}
|
|
}
|
|
|
|
function _computeRestSeconds() {
|
|
if (!_restActive || !_restStartedAt) return;
|
|
const elapsed = Math.floor((Date.now() - _restStartedAt) / 1000);
|
|
_restSeconds = Math.max(0, _restTotal - elapsed);
|
|
if (_restSeconds <= 0) {
|
|
_stopRestTimer();
|
|
_persist();
|
|
}
|
|
}
|
|
|
|
function _startRestInterval() {
|
|
if (_restInterval) clearInterval(_restInterval);
|
|
_restInterval = setInterval(() => _computeRestSeconds(), 1000);
|
|
}
|
|
|
|
function _stopRestTimer() {
|
|
if (_restInterval) {
|
|
clearInterval(_restInterval);
|
|
_restInterval = null;
|
|
}
|
|
_restActive = false;
|
|
_restSeconds = 0;
|
|
_restTotal = 0;
|
|
_restStartedAt = null;
|
|
}
|
|
|
|
// Restore from localStorage on creation
|
|
function restore() {
|
|
const stored = loadFromStorage();
|
|
if (!stored || !stored.active) return;
|
|
|
|
active = true;
|
|
paused = stored.paused;
|
|
name = stored.name;
|
|
templateId = stored.templateId;
|
|
exercises = stored.exercises;
|
|
|
|
if (stored.paused) {
|
|
// Was paused: elapsed is exactly what was saved
|
|
_pausedElapsed = stored.elapsed;
|
|
_elapsed = stored.elapsed;
|
|
startTime = null;
|
|
} else {
|
|
// Was running: add the time that passed since we last saved
|
|
const secondsSinceSave = Math.floor((Date.now() - stored.savedAt) / 1000);
|
|
const totalElapsed = stored.elapsed + secondsSinceSave;
|
|
_pausedElapsed = totalElapsed;
|
|
_elapsed = totalElapsed;
|
|
startTime = new Date(); // start counting from now
|
|
_startTimer();
|
|
}
|
|
|
|
// Restore rest timer if it was active
|
|
if (stored.restStartedAt && stored.restTotal > 0) {
|
|
const elapsed = Math.floor((Date.now() - stored.restStartedAt) / 1000);
|
|
const remaining = stored.restTotal - elapsed;
|
|
if (remaining > 0) {
|
|
_restStartedAt = stored.restStartedAt;
|
|
_restTotal = stored.restTotal;
|
|
_restSeconds = remaining;
|
|
_restActive = true;
|
|
_startRestInterval();
|
|
}
|
|
}
|
|
}
|
|
|
|
function startFromTemplate(template: TemplateData) {
|
|
name = template.name;
|
|
templateId = template._id;
|
|
exercises = template.exercises.map((e) => ({
|
|
exerciseId: e.exerciseId,
|
|
sets: e.sets.length > 0
|
|
? e.sets.map((s) => ({
|
|
reps: s.reps ?? null,
|
|
weight: s.weight ?? null,
|
|
rpe: s.rpe ?? null,
|
|
distance: s.distance ?? null,
|
|
duration: s.duration ?? null,
|
|
completed: false
|
|
}))
|
|
: [createEmptySet()],
|
|
restTime: e.restTime ?? 120
|
|
}));
|
|
startTime = new Date();
|
|
_pausedElapsed = 0;
|
|
_elapsed = 0;
|
|
paused = false;
|
|
active = true;
|
|
_startTimer();
|
|
_persist();
|
|
}
|
|
|
|
function startEmpty() {
|
|
name = 'Quick Workout';
|
|
templateId = null;
|
|
exercises = [];
|
|
startTime = new Date();
|
|
_pausedElapsed = 0;
|
|
_elapsed = 0;
|
|
paused = false;
|
|
active = true;
|
|
_startTimer();
|
|
_persist();
|
|
}
|
|
|
|
function pauseTimer() {
|
|
if (!active || paused) return;
|
|
_computeElapsed();
|
|
_pausedElapsed = _elapsed;
|
|
paused = true;
|
|
startTime = null;
|
|
_stopTimer();
|
|
_persist();
|
|
}
|
|
|
|
function resumeTimer() {
|
|
if (!active || !paused) return;
|
|
paused = false;
|
|
startTime = new Date();
|
|
_startTimer();
|
|
_persist();
|
|
}
|
|
|
|
function addExercise(exerciseId: string) {
|
|
exercises.push({
|
|
exerciseId,
|
|
sets: [createEmptySet()],
|
|
restTime: 120
|
|
});
|
|
_persist();
|
|
}
|
|
|
|
function removeExercise(index: number) {
|
|
exercises.splice(index, 1);
|
|
_persist();
|
|
}
|
|
|
|
function addSet(exerciseIndex: number) {
|
|
const ex = exercises[exerciseIndex];
|
|
if (ex) {
|
|
ex.sets.push(createEmptySet());
|
|
_persist();
|
|
}
|
|
}
|
|
|
|
function removeSet(exerciseIndex: number, setIndex: number) {
|
|
const ex = exercises[exerciseIndex];
|
|
if (ex && ex.sets.length > 1) {
|
|
ex.sets.splice(setIndex, 1);
|
|
_persist();
|
|
}
|
|
}
|
|
|
|
function updateSet(exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) {
|
|
const ex = exercises[exerciseIndex];
|
|
if (ex?.sets[setIndex]) {
|
|
Object.assign(ex.sets[setIndex], data);
|
|
_persist();
|
|
}
|
|
}
|
|
|
|
function toggleSetComplete(exerciseIndex: number, setIndex: number) {
|
|
const ex = exercises[exerciseIndex];
|
|
if (ex?.sets[setIndex]) {
|
|
const wasCompleted = ex.sets[setIndex].completed;
|
|
ex.sets[setIndex].completed = !wasCompleted;
|
|
|
|
if (wasCompleted) {
|
|
// Unticked — cancel rest timer
|
|
_stopRestTimer();
|
|
}
|
|
|
|
_persist();
|
|
}
|
|
}
|
|
|
|
function startRestTimer(seconds: number) {
|
|
_stopRestTimer();
|
|
_restStartedAt = Date.now();
|
|
_restSeconds = seconds;
|
|
_restTotal = seconds;
|
|
_restActive = true;
|
|
_startRestInterval();
|
|
_persist();
|
|
}
|
|
|
|
function cancelRestTimer() {
|
|
_stopRestTimer();
|
|
_persist();
|
|
}
|
|
|
|
function adjustRestTimer(delta: number) {
|
|
if (!_restActive) return;
|
|
_restTotal = Math.max(1, _restTotal + delta);
|
|
// Recompute remaining from the absolute timestamp
|
|
_computeRestSeconds();
|
|
if (_restSeconds <= 0) {
|
|
_stopRestTimer();
|
|
}
|
|
_persist();
|
|
}
|
|
|
|
function finish() {
|
|
_stopTimer();
|
|
_stopRestTimer();
|
|
|
|
const endTime = new Date();
|
|
_computeElapsed();
|
|
|
|
const sessionData = {
|
|
templateId,
|
|
templateName: templateId ? name : undefined,
|
|
name,
|
|
exercises: exercises
|
|
.filter((e) => e.sets.some((s) => s.completed))
|
|
.map((e) => ({
|
|
exerciseId: e.exerciseId,
|
|
name: getExerciseById(e.exerciseId)?.name ?? e.exerciseId,
|
|
sets: e.sets
|
|
.filter((s) => s.completed)
|
|
.map((s) => ({
|
|
reps: s.reps ?? undefined,
|
|
weight: s.weight ?? undefined,
|
|
rpe: s.rpe ?? undefined,
|
|
distance: s.distance ?? undefined,
|
|
duration: s.duration ?? undefined,
|
|
completed: true
|
|
}))
|
|
})),
|
|
startTime: _getOriginalStartTime()?.toISOString() ?? new Date().toISOString(),
|
|
endTime: endTime.toISOString()
|
|
};
|
|
|
|
_reset();
|
|
return sessionData;
|
|
}
|
|
|
|
function _getOriginalStartTime(): Date | null {
|
|
// Compute original start from elapsed
|
|
if (_elapsed > 0) {
|
|
return new Date(Date.now() - _elapsed * 1000);
|
|
}
|
|
return startTime;
|
|
}
|
|
|
|
function _reset() {
|
|
active = false;
|
|
paused = false;
|
|
name = '';
|
|
templateId = null;
|
|
exercises = [];
|
|
startTime = null;
|
|
_pausedElapsed = 0;
|
|
_elapsed = 0;
|
|
clearStorage();
|
|
}
|
|
|
|
function cancel() {
|
|
_stopTimer();
|
|
_stopRestTimer();
|
|
_reset();
|
|
}
|
|
|
|
/** Apply state from another device (merge strategy: incoming wins) */
|
|
function applyRemoteState(remote: RemoteState) {
|
|
name = remote.name;
|
|
templateId = remote.templateId;
|
|
exercises = remote.exercises;
|
|
|
|
if (remote.paused) {
|
|
_stopTimer();
|
|
paused = true;
|
|
_pausedElapsed = remote.elapsed;
|
|
_elapsed = remote.elapsed;
|
|
startTime = null;
|
|
} else {
|
|
paused = false;
|
|
// Account for time elapsed since the remote saved
|
|
const secondsSinceSave = Math.floor((Date.now() - remote.savedAt) / 1000);
|
|
const totalElapsed = remote.elapsed + secondsSinceSave;
|
|
_pausedElapsed = totalElapsed;
|
|
_elapsed = totalElapsed;
|
|
startTime = new Date();
|
|
_startTimer();
|
|
}
|
|
|
|
// Apply rest timer state — skip if already running with same parameters
|
|
const restChanged = remote.restStartedAt !== _restStartedAt || remote.restTotal !== _restTotal;
|
|
if (restChanged) {
|
|
_stopRestTimer();
|
|
if (remote.restStartedAt && remote.restTotal > 0) {
|
|
const elapsed = Math.floor((Date.now() - remote.restStartedAt) / 1000);
|
|
const remaining = remote.restTotal - elapsed;
|
|
if (remaining > 0) {
|
|
_restStartedAt = remote.restStartedAt;
|
|
_restTotal = remote.restTotal;
|
|
_restSeconds = remaining;
|
|
_restActive = true;
|
|
_startRestInterval();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist locally but don't trigger onChange (to avoid re-push loop)
|
|
saveToStorage({
|
|
active: true,
|
|
paused,
|
|
name,
|
|
templateId,
|
|
exercises: JSON.parse(JSON.stringify(exercises)),
|
|
elapsed: _elapsed,
|
|
savedAt: Date.now(),
|
|
restStartedAt: _restActive ? _restStartedAt : null,
|
|
restTotal: _restTotal
|
|
});
|
|
}
|
|
|
|
/** Restore a workout from server when local has no active workout */
|
|
function restoreFromRemote(remote: RemoteState) {
|
|
active = true;
|
|
applyRemoteState(remote);
|
|
}
|
|
|
|
/** Register callback for state changes (used by sync layer) */
|
|
function onChange(cb: () => void) {
|
|
_onChangeCallback = cb;
|
|
}
|
|
|
|
return {
|
|
get active() { return active; },
|
|
get paused() { return paused; },
|
|
get name() { return name; },
|
|
set name(v: string) { name = v; _persist(); },
|
|
get templateId() { return templateId; },
|
|
get exercises() { return exercises; },
|
|
get startTime() { return startTime; },
|
|
get elapsedSeconds() { return _elapsed; },
|
|
get restTimerSeconds() { return _restSeconds; },
|
|
get restTimerTotal() { return _restTotal; },
|
|
get restTimerActive() { return _restActive; },
|
|
get restStartedAt() { return _restStartedAt; },
|
|
restore,
|
|
startFromTemplate,
|
|
startEmpty,
|
|
pauseTimer,
|
|
resumeTimer,
|
|
addExercise,
|
|
removeExercise,
|
|
addSet,
|
|
removeSet,
|
|
updateSet,
|
|
toggleSetComplete,
|
|
startRestTimer,
|
|
cancelRestTimer,
|
|
adjustRestTimer,
|
|
finish,
|
|
cancel,
|
|
applyRemoteState,
|
|
restoreFromRemote,
|
|
onChange
|
|
};
|
|
}
|
|
|
|
/** Shared singleton — use this instead of createWorkout() in components */
|
|
let _instance: ReturnType<typeof createWorkout> | null = null;
|
|
|
|
export function getWorkout() {
|
|
if (!_instance) {
|
|
_instance = createWorkout();
|
|
}
|
|
return _instance;
|
|
}
|