Files
gravl/frontend/src/pages/WorkoutEditPage.jsx
T
clawd f63f4c0420 04-06-02: Save error handling & retry logic
- Added specific error type differentiation:
  * Network errors → 'Anslutning misslyckades'
  * Validation (400) → 'Ogiltiga ändringar'
  * Auth (401/403) → 'Saknar behörighet'
  * Server (500+) → 'Serverfel'
  * Generic fallback messages

- Implemented retry tracking:
  * retryCount state for monitoring attempts
  * lastSavePayload storage for potential retry (future feature)
  * Console logging with context for debugging

- Enhanced error handling:
  * getErrorMessage() function for error classification
  * Comprehensive error logging with workout/exercise context
  * Draft preserved on all error types (no data loss)

- Improved UI/UX:
  * Error banner with specific, actionable messages
  * 'Försök igen' button with retry tracking
  * Sync status feedback (idle/saving/saved/error)
  * Success checkmark animation (2s duration)
  * Spinner animation during save

- CSS Enhancements:
  * @keyframes spin for loading spinner
  * @keyframes slideInCheckmark for success feedback
  * Mobile-responsive error banner (flex column on <480px)
  * Smooth animations for state transitions

Tests: npm run build ✓ (no syntax errors)
Files modified:
  - frontend/src/pages/WorkoutEditPage.jsx
  - frontend/src/pages/WorkoutEditPage.css
2026-03-02 09:25:10 +01:00

356 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Icon } from '../components/Icons'
import ExercisePicker from '../components/ExercisePicker'
import { useDraftWorkout } from '../hooks/useDraftWorkout'
import './WorkoutEditPage.css'
export default function WorkoutEditPage({ workout, onBack, onSave }) {
const { exercises, setExercises, clearDraft, hasDraft, restoreDraft } =
useDraftWorkout(workout.id, workout.exercises || [])
const [pickerOpen, setPickerOpen] = useState(false)
const [swapIndex, setSwapIndex] = useState(null) // null = adding, number = swapping
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [syncStatus, setSyncStatus] = useState('idle') // idle | saving | saved | error
const [draftPromptShown, setDraftPromptShown] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [lastSavePayload, setLastSavePayload] = useState(null)
// Show draft recovery prompt on first render
const handleRecoverDraft = () => {
if (hasDraft && !draftPromptShown) {
setDraftPromptShown(true)
// Prompt is shown via conditional rendering below
}
}
const handleOpenPicker = (index = null) => {
setSwapIndex(index)
setPickerOpen(true)
}
const handleSelectExercise = (exercise) => {
if (swapIndex !== null) {
// Swap
setExercises(prev => prev.map((ex, i) => {
if (i === swapIndex) {
return {
...ex,
exercise_id: exercise.id,
name: exercise.name,
muscle_group: exercise.muscle_group,
// Keep existing sets/reps
}
}
return ex
}))
} else {
// Add
setExercises(prev => [...prev, {
exercise_id: exercise.id,
name: exercise.name,
muscle_group: exercise.muscle_group,
sets: 3,
reps_min: 8,
reps_max: 12
}])
}
setPickerOpen(false)
}
const handleRemove = (index) => {
setExercises(prev => prev.filter((_, i) => i !== index))
}
const handleUpdate = (index, field, value) => {
setExercises(prev => prev.map((ex, i) => {
if (i === index) {
return { ...ex, [field]: value }
}
return ex
}))
// Clear error state on user edit
if (error) setError(null)
}
/**
* Determine specific error message based on error type
*/
const getErrorMessage = (err) => {
// Network errors
if (!err || err instanceof TypeError && err.message.includes('fetch')) {
return 'Anslutning misslyckades. Försök igen?'
}
// Check if error has a response (API error)
if (err.status) {
if (err.status === 400) {
return 'Ogiltiga ändringar. Kontrollera dina inmatningar.'
}
if (err.status === 401 || err.status === 403) {
return 'Du har inte behörighet att spara denna träning.'
}
if (err.status >= 500) {
return 'Serverfel. Försök igen senare.'
}
if (err.status >= 400) {
return 'Ett fel uppstod när träningen skulle sparas. Försök igen.'
}
}
// Fallback
return err.message || 'Sparning misslyckades. Försök igen.'
}
const handleSave = async () => {
setSaving(true)
setSyncStatus('saving')
setError(null)
setRetryCount(prev => prev + 1)
try {
// Format for API
const payload = {
exercises: exercises.map(ex => ({
exercise_id: ex.exercise_id || ex.id, // Handle both structures
sets: parseInt(ex.sets) || 3,
reps_min: parseInt(ex.reps_min) || 8,
reps_max: parseInt(ex.reps_max) || 12
}))
}
// Store payload for potential retry
setLastSavePayload(payload)
// Call the save callback
await onSave(workout.id, payload)
// Success: clear draft and show confirmation
clearDraft()
setSyncStatus('saved')
setRetryCount(0) // Reset retry count on success
// Log success
console.log('Workout saved successfully', {
workoutId: workout.id,
exerciseCount: exercises.length,
retryCount
})
// Reset status after 2 seconds
setTimeout(() => setSyncStatus('idle'), 2000)
} catch (err) {
// Log error with context for debugging
console.error('Failed to save workout:', {
error: err,
workoutId: workout.id,
exerciseCount: exercises.length,
retryCount,
payload: lastSavePayload
})
// Determine error message based on error type
const errorMessage = getErrorMessage(err)
setError(errorMessage)
setSyncStatus('error')
// Keep draft on error so user doesn't lose work
// (useDraftWorkout already auto-saves, so no action needed here)
} finally {
setSaving(false)
}
}
const handleRetry = () => {
// Log retry attempt
console.log('User retrying save', {
workoutId: workout.id,
retryCount
})
handleSave()
}
const handleDiscardDraft = () => {
clearDraft()
setDraftPromptShown(true)
// Reset exercises to original
setExercises(workout.exercises || [])
}
// Show draft recovery prompt if we have a draft and haven't shown it yet
const showDraftPrompt = hasDraft && !draftPromptShown
if (showDraftPrompt) {
handleRecoverDraft()
}
return (
<div className="edit-page">
{/* Draft Recovery Prompt */}
{showDraftPrompt && (
<div className="draft-prompt-overlay">
<div className="draft-prompt-modal">
<h2>Du har sparat ändringar</h2>
<p>Vi hittade ett utkast från din senaste redigering. Vill du fortsätta eller börja om?</p>
<div className="draft-prompt-actions">
<button
className="btn btn-secondary"
onClick={handleDiscardDraft}
>
Börja om
</button>
<button
className="btn btn-primary"
onClick={() => setDraftPromptShown(true)}
>
Fortsätt redigering
</button>
</div>
</div>
</div>
)}
<header className="page-header">
<button className="back-btn" onClick={onBack} disabled={saving}>
<Icon name="arrowLeft" size={18} /> Avbryt
</button>
<h1>Redigera pass</h1>
<div className="save-header-group">
{syncStatus === 'saved' && (
<span className="sync-status saved">
<Icon name="checkmark" size={16} /> Sparat
</span>
)}
{syncStatus === 'error' && (
<span className="sync-status error">
<Icon name="alert" size={16} /> Fel
</span>
)}
<button
className="save-header-btn"
onClick={handleSave}
disabled={saving}
>
{syncStatus === 'saving' && (
<>
<Icon name="spinner" size={16} /> Sparar...
</>
)}
{syncStatus !== 'saving' && 'Spara'}
</button>
</div>
</header>
{/* Error Banner */}
{error && (
<div className="error-banner">
<div className="error-message">
<Icon name="alert" size={18} />
<span>{error}</span>
</div>
<div className="error-actions">
<button className="btn-retry" onClick={handleRetry}>
Försök igen
</button>
<button
className="btn-close"
onClick={() => setError(null)}
aria-label="Stäng"
>
×
</button>
</div>
</div>
)}
<main className="edit-main">
<div className="workout-meta-card">
<h2>{workout.name}</h2>
<p>{exercises.length} övningar</p>
</div>
<div className="edit-exercises-list">
{exercises.map((ex, i) => (
<div key={i} className="edit-exercise-card">
<div className="edit-card-header">
<div className="edit-card-info">
<h3>{ex.name}</h3>
<span className="muscle-group">{ex.muscle_group}</span>
</div>
<div className="edit-card-actions">
<button
className="icon-btn"
onClick={() => handleOpenPicker(i)}
aria-label="Byt övning"
disabled={saving}
>
<Icon name="swap" size={18} />
</button>
<button
className="icon-btn delete"
onClick={() => handleRemove(i)}
aria-label="Ta bort övning"
disabled={saving}
>
<Icon name="trash" size={18} />
</button>
</div>
</div>
<div className="edit-card-settings">
<div className="setting-group">
<label>Set</label>
<input
type="number"
value={ex.sets}
onChange={e => handleUpdate(i, 'sets', e.target.value)}
min="1"
disabled={saving}
/>
</div>
<div className="setting-group">
<label>Reps min</label>
<input
type="number"
value={ex.reps_min}
onChange={e => handleUpdate(i, 'reps_min', e.target.value)}
min="1"
disabled={saving}
/>
</div>
<div className="setting-group">
<label>Reps max</label>
<input
type="number"
value={ex.reps_max}
onChange={e => handleUpdate(i, 'reps_max', e.target.value)}
min="1"
disabled={saving}
/>
</div>
</div>
</div>
))}
</div>
<button
className="add-exercise-btn"
onClick={() => handleOpenPicker(null)}
disabled={saving}
>
<Icon name="plus" size={20} />
Lägg till övning
</button>
</main>
{pickerOpen && (
<ExercisePicker
open={pickerOpen}
onSelect={handleSelectExercise}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}