04-05: Reset to Original feature - custom workouts can be reverted to program versions

- Added reset button (refresh icon) to custom workout cards
- Implemented confirmation dialog to prevent accidental resets
- Integrated with DELETE /api/custom-workouts/:id endpoint
- Added CSS styling: reset button, success message, modal dialog
- Added refresh icon to SVG library
- Frontend build successful

Changes:
- frontend/src/pages/WorkoutSelectPage.jsx (reset flow logic)
- frontend/src/App.css (170 new lines for reset/modal styling)
- frontend/src/components/Icons.jsx (refresh icon)
- Checkpoint updated with task completion metadata
This commit is contained in:
2026-03-01 20:44:45 +01:00
parent 7789e7177d
commit fcf642af2a
8 changed files with 358 additions and 79 deletions
+16 -9
View File
@@ -1,13 +1,20 @@
{ {
"lastRun": "2026-03-01T17:38:00+01:00", "lastRun": "2026-03-01T20:42:00+01:00",
"status": "completed", "status": "completed",
"phase": "04-workout-modification", "phase": "04-workout-modification",
"activeTask": "04-03-frontend-workout-edit", "activeTask": "04-05-reset-to-original",
"tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api", "04-03-frontend-workout-edit"], "tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api", "04-03-frontend-workout-edit", "04-04-visual-distinction", "04-05-reset-to-original"],
"nextTask": "04-04-visual-distinction", "nextTask": "04-06-persistence-improvements",
"agentSession": "claude-code-frontend", "agentSession": "local-exec",
"agentType": "claude-code-local-exec", "agentType": "gravl-pm-cron",
"spawnTime": "2026-03-01T17:38:00+01:00", "spawnTime": "2026-03-01T20:42:00+01:00",
"result": "Phase 04-03 complete. Edit workflow implemented: ExercisePicker modal, swap/add/remove exercise flows, fork confirmation dialog, API integration (POST/PUT custom-workouts). All success criteria met. Ready for 04-04.", "result": "Phase 04-05 complete. Reset to Original feature fully implemented. Changes: 1) Added refresh button to custom workout cards (visible only on 'Anpassad' workouts). 2) Implemented handleResetClick + handleConfirmReset flow with confirmation dialog ('Är du säker? Dina ändringar kommer att försvinna...'). 3) DELETE /api/custom-workouts/:id endpoint verified (exists in backend). 4) Added CSS styling: .reset-btn (orange icon button with hover effects), .success-message (green slide-down animation), .modal-overlay/.modal-dialog/.modal-btn (reusable confirmation dialog). 5) Added refresh icon to Icons.jsx SVG library. Frontend build successful with no errors.",
"notes": "Previous attempt hit Gemini quota limit. Recovered at 17:38. Advancing to 04-04: Add visual distinction badges (custom vs program) on WorkoutSelectPage." "notes": "Task 04-05 complete. Custom workouts can now be reset to original program versions. User gets confirmation dialog before deletion. UI updates show badge change from 'Anpassad' to 'Program' after reset. Next: 04-06 (persistence improvements or advanced features like workout export/backup).",
"filesModified": [
"frontend/src/pages/WorkoutSelectPage.jsx",
"frontend/src/App.css",
"frontend/src/components/Icons.jsx"
],
"buildStatus": "success",
"buildTime": "3.59s"
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Gravl - Träning</title> <title>Gravl - Träning</title>
<script type="module" crossorigin src="/assets/index-DLV768U5.js"></script> <script type="module" crossorigin src="/assets/index-kl2SjtTw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VYqTaBQ1.css"> <link rel="stylesheet" crossorigin href="/assets/index-D0xrERyI.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+170
View File
@@ -2998,3 +2998,173 @@
.workout-select-card:hover .workout-badge { .workout-select-card:hover .workout-badge {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
} }
/* Reset button for custom workouts */
.reset-btn {
position: absolute;
top: -8px;
right: -8px;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--accent);
border: 2px solid var(--bg-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
padding: 0;
min-width: 32px;
min-height: 32px;
}
.reset-btn:hover {
background: #e85a3c;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.4);
}
.reset-btn:active {
transform: scale(0.95);
}
/* Success message */
.success-message {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, var(--success), #16a34a);
color: white;
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
animation: slideDown 0.3s ease;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Modal dialog styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-dialog {
background: var(--bg-primary);
border-radius: var(--radius-xl);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 90%;
animation: slideUp 0.3s ease;
overflow: hidden;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: var(--font-lg);
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
.modal-body {
padding: var(--space-4);
}
.modal-body p {
font-size: var(--font-md);
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.modal-footer {
padding: var(--space-4);
border-top: 1px solid var(--border);
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.modal-btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-size: var(--font-md);
font-weight: 600;
transition: all 0.2s ease;
min-height: 40px;
}
.modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-btn.cancel {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.modal-btn.cancel:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--border);
}
.modal-btn.confirm {
background: var(--accent);
color: white;
}
.modal-btn.confirm:hover:not(:disabled) {
background: #e85a3c;
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
}
.modal-btn.confirm:active:not(:disabled) {
transform: scale(0.98);
}
+6
View File
@@ -261,6 +261,12 @@ export const Icons = {
<line x1="8" y1="8" x2="16" y2="16"/> <line x1="8" y1="8" x2="16" y2="16"/>
</svg> </svg>
), ),
refresh: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
),
} }
// Icon component wrapper // Icon component wrapper
+96
View File
@@ -20,12 +20,23 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
const [customWorkouts, setCustomWorkouts] = useState([]) const [customWorkouts, setCustomWorkouts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedWorkout, setSelectedWorkout] = useState(null) const [selectedWorkout, setSelectedWorkout] = useState(null)
const [resetConfirm, setResetConfirm] = useState(null)
const [resetting, setResetting] = useState(false)
const [successMessage, setSuccessMessage] = useState(null)
useEffect(() => { useEffect(() => {
fetchProgram() fetchProgram()
fetchCustomWorkouts() fetchCustomWorkouts()
}, []) }, [])
// Auto-clear success message after 3 seconds
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000)
return () => clearTimeout(timer)
}
}, [successMessage])
const fetchProgram = async () => { const fetchProgram = async () => {
try { try {
const res = await fetch(`${API_URL}/programs/1`) const res = await fetch(`${API_URL}/programs/1`)
@@ -52,6 +63,11 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
} }
} }
const getCustomWorkoutId = (programDayId) => {
const customWorkout = customWorkouts.find(cw => cw.source_program_day_id === programDayId)
return customWorkout?.id
}
const isWorkoutCustom = (programDayId) => { const isWorkoutCustom = (programDayId) => {
return customWorkouts.some(cw => cw.source_program_day_id === programDayId) return customWorkouts.some(cw => cw.source_program_day_id === programDayId)
} }
@@ -66,6 +82,38 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
} }
} }
const handleResetClick = (e, workoutId) => {
e.stopPropagation()
setResetConfirm(workoutId)
}
const handleConfirmReset = async () => {
if (!resetConfirm) return
setResetting(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}/custom-workouts/${resetConfirm}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (res.ok) {
// Refresh custom workouts list
await fetchCustomWorkouts()
setSuccessMessage('Passet återställdes till original')
setSelectedWorkout(null)
setResetConfirm(null)
} else {
console.error('Failed to reset workout:', res.status)
}
} catch (err) {
console.error('Error resetting workout:', err)
} finally {
setResetting(false)
}
}
if (loading) { if (loading) {
return ( return (
<div className="select-page loading"> <div className="select-page loading">
@@ -90,6 +138,13 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
Vilken träning vill du köra idag? Vilken träning vill du köra idag?
</p> </p>
{successMessage && (
<div className="success-message">
<Icon name="check" size={18} />
{successMessage}
</div>
)}
<div className="workout-grid"> <div className="workout-grid">
{program?.days?.map((workout) => { {program?.days?.map((workout) => {
const iconName = getWorkoutIconName(workout.name) const iconName = getWorkoutIconName(workout.name)
@@ -97,6 +152,7 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
const isSelected = selectedWorkout?.id === workout.id const isSelected = selectedWorkout?.id === workout.id
const exerciseCount = workout.exercises?.filter(e => e.name).length || 0 const exerciseCount = workout.exercises?.filter(e => e.name).length || 0
const isCustom = isWorkoutCustom(workout.id) const isCustom = isWorkoutCustom(workout.id)
const customWorkoutId = getCustomWorkoutId(workout.id)
return ( return (
<div <div
@@ -112,6 +168,16 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
<span className={`workout-badge ${isCustom ? 'custom' : 'program'}`}> <span className={`workout-badge ${isCustom ? 'custom' : 'program'}`}>
{isCustom ? 'Anpassad' : 'Program'} {isCustom ? 'Anpassad' : 'Program'}
</span> </span>
{isCustom && (
<button
className="reset-btn"
title="Återställ till original"
onClick={(e) => handleResetClick(e, customWorkoutId)}
aria-label="Återställ workout"
>
<Icon name="refresh" size={16} />
</button>
)}
</div> </div>
<div className="workout-details"> <div className="workout-details">
<h3>{workout.name}</h3> <h3>{workout.name}</h3>
@@ -146,6 +212,36 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
</div> </div>
)} )}
</main> </main>
{/* Reset confirmation dialog */}
{resetConfirm && (
<div className="modal-overlay" onClick={() => setResetConfirm(null)}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Återställ till original?</h2>
</div>
<div className="modal-body">
<p>Är du säker? Dina ändringar kommer att försvinna och passet återställs till programversionen.</p>
</div>
<div className="modal-footer">
<button
className="modal-btn cancel"
onClick={() => setResetConfirm(null)}
disabled={resetting}
>
Avbryt
</button>
<button
className="modal-btn confirm"
onClick={handleConfirmReset}
disabled={resetting}
>
{resetting ? 'Återställer...' : 'Återställ'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }