feat: RestDaysPage UI with Quick Mode presets (v9d Phase 2a)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

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:
Lars 2026-03-22 16:33:32 +01:00
parent b63d15fd02
commit c265ab1245
3 changed files with 496 additions and 0 deletions

View File

@ -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/>}/>

View File

@ -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',

View 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>
)
}