feat: RestDaysPage UI with Quick Mode presets (v9d Phase 2a)
Quick Mode with 4 presets: - 💪 Kraft-Ruhetag (strength/hiit pause, cardio allowed, max 60%) - 🏃 Cardio-Ruhetag (cardio pause, strength/mobility allowed, max 70%) - 🧘 Entspannungstag (all pause, only meditation/walk, max 40%) - 📉 Deload (all allowed, max 70% intensity) Features: - Preset selection with visual cards - Date picker - Optional note field - List view with inline editing - Delete with confirmation - Toast notifications - Detail view (shows rest_from, allows, intensity_max) Integration: - Route: /rest-days - CaptureHub entry: 🛌 Ruhetage Next Phase: - Custom Mode (full control) - Activity conflict warnings - Weekly planning integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b63d15fd02
commit
c265ab1245
|
|
@ -31,6 +31,7 @@ import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import SleepPage from './pages/SleepPage'
|
import SleepPage from './pages/SleepPage'
|
||||||
|
import RestDaysPage from './pages/RestDaysPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -166,6 +167,7 @@ function AppShell() {
|
||||||
<Route path="/caliper" element={<CaliperScreen/>}/>
|
<Route path="/caliper" element={<CaliperScreen/>}/>
|
||||||
<Route path="/history" element={<History/>}/>
|
<Route path="/history" element={<History/>}/>
|
||||||
<Route path="/sleep" element={<SleepPage/>}/>
|
<Route path="/sleep" element={<SleepPage/>}/>
|
||||||
|
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||||
<Route path="/activity" element={<ActivityPage/>}/>
|
<Route path="/activity" element={<ActivityPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<Route path="/analysis" element={<Analysis/>}/>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,13 @@ const ENTRIES = [
|
||||||
to: '/sleep',
|
to: '/sleep',
|
||||||
color: '#7B68EE',
|
color: '#7B68EE',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '🛌',
|
||||||
|
label: 'Ruhetage',
|
||||||
|
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
|
||||||
|
to: '/rest-days',
|
||||||
|
color: '#9B59B6',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '📖',
|
icon: '📖',
|
||||||
label: 'Messanleitung',
|
label: 'Messanleitung',
|
||||||
|
|
|
||||||
487
frontend/src/pages/RestDaysPage.jsx
Normal file
487
frontend/src/pages/RestDaysPage.jsx
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Pencil, Trash2, Check, X } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import 'dayjs/locale/de'
|
||||||
|
dayjs.locale('de')
|
||||||
|
|
||||||
|
// Quick Mode Presets
|
||||||
|
const PRESETS = [
|
||||||
|
{
|
||||||
|
id: 'muscle_recovery',
|
||||||
|
icon: '💪',
|
||||||
|
label: 'Kraft-Ruhetag',
|
||||||
|
description: 'Muskelregeneration – Kraft & HIIT pausieren, Cardio erlaubt',
|
||||||
|
color: '#D85A30',
|
||||||
|
config: {
|
||||||
|
focus: 'muscle_recovery',
|
||||||
|
rest_from: ['strength', 'hiit'],
|
||||||
|
allows: ['cardio_low', 'meditation', 'mobility', 'walk'],
|
||||||
|
intensity_max: 60,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cardio_recovery',
|
||||||
|
icon: '🏃',
|
||||||
|
label: 'Cardio-Ruhetag',
|
||||||
|
description: 'Ausdauererholung – Cardio pausieren, Kraft & Mobility erlaubt',
|
||||||
|
color: '#378ADD',
|
||||||
|
config: {
|
||||||
|
focus: 'cardio_recovery',
|
||||||
|
rest_from: ['cardio', 'cardio_low', 'cardio_high'],
|
||||||
|
allows: ['strength', 'mobility', 'meditation'],
|
||||||
|
intensity_max: 70,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mental_rest',
|
||||||
|
icon: '🧘',
|
||||||
|
label: 'Entspannungstag',
|
||||||
|
description: 'Mentale Erholung – Nur Meditation & Spaziergang',
|
||||||
|
color: '#7B68EE',
|
||||||
|
config: {
|
||||||
|
focus: 'mental_rest',
|
||||||
|
rest_from: ['strength', 'cardio', 'hiit', 'power'],
|
||||||
|
allows: ['meditation', 'walk'],
|
||||||
|
intensity_max: 40,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deload',
|
||||||
|
icon: '📉',
|
||||||
|
label: 'Deload',
|
||||||
|
description: 'Reduzierte Intensität – Alles erlaubt, max 70% Last',
|
||||||
|
color: '#E67E22',
|
||||||
|
config: {
|
||||||
|
focus: 'deload',
|
||||||
|
rest_from: [],
|
||||||
|
allows: ['strength', 'cardio', 'mobility', 'meditation'],
|
||||||
|
intensity_max: 70,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const FOCUS_LABELS = {
|
||||||
|
muscle_recovery: '💪 Muskelregeneration',
|
||||||
|
cardio_recovery: '🏃 Cardio-Erholung',
|
||||||
|
mental_rest: '🧘 Mentale Erholung',
|
||||||
|
deload: '📉 Deload',
|
||||||
|
injury: '🩹 Verletzungspause',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RestDaysPage() {
|
||||||
|
const [restDays, setRestDays] = useState([])
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
preset: null,
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRestDays()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRestDays = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.listRestDays(90)
|
||||||
|
setRestDays(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load rest days:', err)
|
||||||
|
setError('Fehler beim Laden der Ruhetage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = (message, duration = 2000) => {
|
||||||
|
setToast(message)
|
||||||
|
setTimeout(() => setToast(null), duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePresetSelect = (preset) => {
|
||||||
|
setFormData(f => ({ ...f, preset: preset.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.preset) {
|
||||||
|
setError('Bitte wähle einen Ruhetag-Typ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preset = PRESETS.find(p => p.id === formData.preset)
|
||||||
|
await api.createRestDay({
|
||||||
|
date: formData.date,
|
||||||
|
rest_config: preset.config,
|
||||||
|
note: formData.note,
|
||||||
|
})
|
||||||
|
|
||||||
|
showToast('✓ Ruhetag gespeichert')
|
||||||
|
await loadRestDays()
|
||||||
|
setShowForm(false)
|
||||||
|
setFormData({
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
preset: null,
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err)
|
||||||
|
setError(err.message || 'Fehler beim Speichern')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Ruhetag löschen?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteRestDay(id)
|
||||||
|
showToast('✓ Gelöscht')
|
||||||
|
await loadRestDays()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
setError(err.message || 'Fehler beim Löschen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (day) => {
|
||||||
|
setEditing({
|
||||||
|
...day,
|
||||||
|
note: day.note || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditing(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await api.updateRestDay(editing.id, {
|
||||||
|
note: editing.note,
|
||||||
|
})
|
||||||
|
showToast('✓ Aktualisiert')
|
||||||
|
setEditing(null)
|
||||||
|
await loadRestDays()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err)
|
||||||
|
setError(err.message || 'Fehler beim Aktualisieren')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Ruhetage</h1>
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 70,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
zIndex: 1000,
|
||||||
|
animation: 'slideDown 0.3s ease',
|
||||||
|
}}>
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Entry Form */}
|
||||||
|
{!showForm ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
>
|
||||||
|
+ Ruhetag erfassen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="card-title">Neuer Ruhetag</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={formData.date}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<label className="form-label" style={{ marginBottom: 8 }}>Typ wählen:</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{PRESETS.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => handlePresetSelect(preset)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: formData.preset === preset.id
|
||||||
|
? `2px solid ${preset.color}`
|
||||||
|
: '1.5px solid var(--border)',
|
||||||
|
background: formData.preset === preset.id
|
||||||
|
? `${preset.color}14`
|
||||||
|
: 'var(--surface)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 24,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: `${preset.color}22`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{preset.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: formData.preset === preset.id ? preset.color : 'var(--text1)',
|
||||||
|
marginBottom: 2,
|
||||||
|
}}>
|
||||||
|
{preset.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
{preset.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.preset === preset.id && (
|
||||||
|
<Check size={18} style={{ color: preset.color, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<div className="form-row" style={{ marginTop: 16 }}>
|
||||||
|
<label className="form-label">Notiz</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="optional"
|
||||||
|
value={formData.note}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
background: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
marginTop: 8,
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false)
|
||||||
|
setFormData({
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
preset: null,
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="section-gap">
|
||||||
|
<div className="card-title" style={{ marginBottom: 8 }}>
|
||||||
|
Verlauf ({restDays.length})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{restDays.length === 0 && (
|
||||||
|
<p className="muted">Noch keine Ruhetage erfasst.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restDays.map(day => {
|
||||||
|
const isEditing = editing?.id === day.id
|
||||||
|
const focus = day.rest_config?.focus || 'mental_rest'
|
||||||
|
const preset = PRESETS.find(p => p.id === focus)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day.id} className="card" style={{ marginBottom: 8 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
||||||
|
{dayjs(day.date).format('DD. MMMM YYYY')}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Notiz</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={editing.note}
|
||||||
|
onChange={e => setEditing(d => ({ ...d, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
>
|
||||||
|
<Check size={13} /> Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X size={13} /> Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{dayjs(day.date).format('DD. MMMM YYYY')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '5px 8px' }}
|
||||||
|
onClick={() => startEdit(day)}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
style={{ padding: '5px 8px' }}
|
||||||
|
onClick={() => handleDelete(day.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Badge */}
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: `${preset?.color || '#888'}22`,
|
||||||
|
border: `1px solid ${preset?.color || '#888'}`,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: preset?.color || '#888',
|
||||||
|
marginBottom: day.note ? 8 : 0,
|
||||||
|
}}>
|
||||||
|
{preset?.icon || '📅'} {FOCUS_LABELS[focus] || focus}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{day.note && (
|
||||||
|
<p style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: 6,
|
||||||
|
}}>
|
||||||
|
"{day.note}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details (collapsible, optional for later) */}
|
||||||
|
{day.rest_config && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: 'var(--bg)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
}}>
|
||||||
|
{day.rest_config.rest_from?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Pausiert:</strong> {day.rest_config.rest_from.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.rest_config.allows?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Erlaubt:</strong> {day.rest_config.allows.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.rest_config.intensity_max && (
|
||||||
|
<div>
|
||||||
|
<strong>Max Intensität:</strong> {day.rest_config.intensity_max}% HFmax
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user