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 SubscriptionPage from './pages/SubscriptionPage'
|
||||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -166,6 +167,7 @@ function AppShell() {
|
|||
<Route path="/caliper" element={<CaliperScreen/>}/>
|
||||
<Route path="/history" element={<History/>}/>
|
||||
<Route path="/sleep" element={<SleepPage/>}/>
|
||||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||
<Route path="/activity" element={<ActivityPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ const ENTRIES = [
|
|||
to: '/sleep',
|
||||
color: '#7B68EE',
|
||||
},
|
||||
{
|
||||
icon: '🛌',
|
||||
label: 'Ruhetage',
|
||||
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
|
||||
to: '/rest-days',
|
||||
color: '#9B59B6',
|
||||
},
|
||||
{
|
||||
icon: '📖',
|
||||
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