Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions src/db/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import {
getMesocycle,
updateMesocycle,
deleteMesocycle,
recoverActiveWorkout,
autoSaveWorkout,
clearAutoSavedWorkout,
} from '../db/service';

describe('Database Service - User Profiles', () => {
Expand Down Expand Up @@ -749,3 +752,187 @@ describe('Database Service - Data Flow Integration', () => {
expect(mesocycle?.status).toBe('active');
});
});

describe('Workout Recovery Functions', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});

afterEach(() => {
localStorage.clear();
});

describe('recoverActiveWorkout', () => {
it('should return null when no activeWorkout in localStorage', () => {
const result = recoverActiveWorkout();
expect(result).toBeNull();
});

it('should return null and clear localStorage when JSON is malformed', () => {
localStorage.setItem('activeWorkout', 'invalid-json{');
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return null and clear localStorage when JSON is not an object (null)', () => {
localStorage.setItem('activeWorkout', 'null');
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return null and clear localStorage when JSON is not an object (string)', () => {
localStorage.setItem('activeWorkout', '"just a string"');
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return null and clear localStorage when JSON is not an object (number)', () => {
localStorage.setItem('activeWorkout', '42');
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return null and clear localStorage when JSON is not an object (array)', () => {
localStorage.setItem('activeWorkout', '[]');
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return null and clear localStorage when workout is completed', () => {
const completedWorkout = {
id: 'workout-1',
date: new Date().toISOString(),
exercises: [],
completed: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localStorage.setItem('activeWorkout', JSON.stringify(completedWorkout));
const result = recoverActiveWorkout();
expect(result).toBeNull();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should return incomplete workout with revived Date objects', () => {
const now = new Date();
const incompleteWorkout = {
id: 'workout-1',
date: now.toISOString(),
exercises: [],
completed: false,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
localStorage.setItem('activeWorkout', JSON.stringify(incompleteWorkout));
const result = recoverActiveWorkout();

expect(result).not.toBeNull();
expect(result?.id).toBe('workout-1');
expect(result?.completed).toBe(false);
// Check that dates were revived as Date objects
expect(result?.date).toBeInstanceOf(Date);
expect(result?.createdAt).toBeInstanceOf(Date);
expect(result?.updatedAt).toBeInstanceOf(Date);
// localStorage should NOT be cleared for incomplete workouts
expect(localStorage.getItem('activeWorkout')).not.toBeNull();
});

it('should handle ISO date strings with milliseconds', () => {
const now = new Date();
const incompleteWorkout = {
id: 'workout-1',
date: now.toISOString(), // Format: 2026-02-10T09:02:02.270Z
exercises: [],
completed: false,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
localStorage.setItem('activeWorkout', JSON.stringify(incompleteWorkout));
const result = recoverActiveWorkout();

expect(result?.date).toBeInstanceOf(Date);
expect(result?.date.getTime()).toBeCloseTo(now.getTime(), -1);
});

it('should handle ISO date strings without milliseconds', () => {
const dateWithoutMs = '2026-02-10T09:02:02Z';
const incompleteWorkout = {
id: 'workout-1',
date: dateWithoutMs,
exercises: [],
completed: false,
createdAt: dateWithoutMs,
updatedAt: dateWithoutMs,
};
localStorage.setItem('activeWorkout', JSON.stringify(incompleteWorkout));
const result = recoverActiveWorkout();

expect(result?.date).toBeInstanceOf(Date);
expect(result?.date.toISOString()).toBe('2026-02-10T09:02:02.000Z');
});
});

describe('autoSaveWorkout', () => {
it('should save workout to localStorage', () => {
const workout = {
id: 'workout-1',
date: new Date(),
exercises: [],
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
};

autoSaveWorkout(workout);
const saved = localStorage.getItem('activeWorkout');
expect(saved).not.toBeNull();

const parsed = JSON.parse(saved!);
expect(parsed.id).toBe('workout-1');
expect(parsed.completed).toBe(false);
});

it('should not throw on localStorage errors', () => {
const workout = {
id: 'workout-1',
date: new Date(),
exercises: [],
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
};

// Mock localStorage.setItem to throw
const originalSetItem = localStorage.setItem;
localStorage.setItem = () => {
throw new Error('localStorage is full');
};

// Should not throw
expect(() => autoSaveWorkout(workout)).not.toThrow();

// Restore
localStorage.setItem = originalSetItem;
});
});

describe('clearAutoSavedWorkout', () => {
it('should remove activeWorkout from localStorage', () => {
localStorage.setItem('activeWorkout', 'some-data');
expect(localStorage.getItem('activeWorkout')).not.toBeNull();

clearAutoSavedWorkout();
expect(localStorage.getItem('activeWorkout')).toBeNull();
});

it('should not throw when activeWorkout does not exist', () => {
expect(() => clearAutoSavedWorkout()).not.toThrow();
});
});
});
21 changes: 17 additions & 4 deletions src/db/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,10 +605,9 @@ export async function autoSaveWorkout(

/**
* Recover an active workout from localStorage
* Only returns incomplete workouts - completed workouts are not recovered
*/
export function recoverActiveWorkout():
| import('../types/models').Workout
| null {
export function recoverActiveWorkout(): Workout | null {
try {
const saved = localStorage.getItem('activeWorkout');
if (!saved) {
Expand All @@ -626,9 +625,23 @@ export function recoverActiveWorkout():
return value;
});

return workout;
// Validate that we have a non-null object (but not an array)
if (!workout || typeof workout !== 'object' || Array.isArray(workout)) {
localStorage.removeItem('activeWorkout');
return null;
}

// Don't recover completed workouts - they should be cleared
if ((workout as Workout).completed) {
localStorage.removeItem('activeWorkout');
return null;
}

return workout as Workout;
} catch (error) {
console.error('Failed to recover active workout:', error);
// Clear corrupted data
localStorage.removeItem('activeWorkout');
return null;
}
}
Expand Down
85 changes: 60 additions & 25 deletions src/hooks/useWorkoutSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,31 @@ export function useWorkoutSession(): UseWorkoutSessionReturn {
const [currentExerciseIndex, setCurrentExerciseIndex] = useState(0);
const autoSaveTimerRef = useRef<number | null>(null);

// Use refs to track current state for auto-save to avoid closure issues
const workoutRef = useRef<Workout | null>(workout);
const isActiveRef = useRef<boolean>(isActive);

// Keep refs in sync with state
useEffect(() => {
workoutRef.current = workout;
isActiveRef.current = isActive;
}, [workout, isActive]);

// Auto-save active workout
useEffect(() => {
if (!workout || !isActive) {
// Only auto-save if workout is active and not completed
if (!workout || !isActive || workout.completed) {
return;
}

const save = () => {
autoSaveWorkout(workout);
// Read from refs to get current state, not closure-captured values
const currentWorkout = workoutRef.current;
const currentIsActive = isActiveRef.current;

if (currentWorkout && currentIsActive && !currentWorkout.completed) {
autoSaveWorkout(currentWorkout);
}
Comment on lines 82 to +89
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “double-check” inside save() doesn’t actually validate current state because workout/isActive are captured by the effect closure; if isActive is set to false, there’s a window before the effect cleanup runs where the interval can still fire and this check will still see the old isActive === true. If the goal is to prevent saves after ending/canceling, clear the interval synchronously in endWorkout/cancelWorkout and/or read workout/isActive from refs updated on render.

Copilot uses AI. Check for mistakes.
};

// Save immediately
Expand Down Expand Up @@ -180,6 +197,12 @@ export function useWorkoutSession(): UseWorkoutSessionReturn {
async (feedback?: WorkoutFeedback) => {
if (!workout) return;

// Stop auto-save interval immediately to prevent race conditions
if (autoSaveTimerRef.current) {
window.clearInterval(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}

const duration = Math.round(
(new Date().getTime() - workout.date.getTime()) / 60000
); // minutes
Expand All @@ -192,38 +215,50 @@ export function useWorkoutSession(): UseWorkoutSessionReturn {
updatedAt: new Date(),
};

// Save to database
if (workout.id.startsWith('temp-workout-')) {
// Create new workout
const id = await createWorkout({
date: completedWorkout.date,
splitDayId: completedWorkout.splitDayId,
exercises: completedWorkout.exercises,
notes: completedWorkout.notes,
completed: true,
duration,
feedback: completedWorkout.feedback,
});
completedWorkout.id = id;
} else {
// Update existing workout
await updateWorkout(workout.id, completedWorkout);
}
try {
// Save to database
if (workout.id.startsWith('temp-workout-')) {
// Create new workout
const id = await createWorkout({
date: completedWorkout.date,
splitDayId: completedWorkout.splitDayId,
exercises: completedWorkout.exercises,
notes: completedWorkout.notes,
completed: true,
duration,
feedback: completedWorkout.feedback,
});
completedWorkout.id = id;
} else {
// Update existing workout
await updateWorkout(workout.id, completedWorkout);
}

// Clear auto-saved data
clearAutoSavedWorkout();
// Only clear localStorage after successful DB save
clearAutoSavedWorkout();

// Reset state
setWorkout(null);
setIsActive(false);
setCurrentExerciseIndex(0);
// Reset state
setWorkout(null);
setIsActive(false);
setCurrentExerciseIndex(0);
} catch (error) {
// On error, keep the workout in localStorage so it can be recovered
console.error('Failed to save workout to database:', error);
throw error;
}
},
[workout]
);

const cancelWorkout = useCallback(() => {
if (!workout) return;

// Stop auto-save interval immediately
if (autoSaveTimerRef.current) {
window.clearInterval(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}

// Clear auto-saved data
clearAutoSavedWorkout();

Expand Down