Production Release: RestDays Widget + Trainingstyp Fix #16

Merged
Lars merged 10 commits from develop into main 2026-03-23 09:24:17 +01:00
13 changed files with 1156 additions and 9 deletions

View File

@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
from routers import user_restrictions, access_grants, training_types, admin_training_types
from routers import admin_activity_mappings, sleep
from routers import admin_activity_mappings, sleep, rest_days
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -86,11 +86,12 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (admin)
# v9d Training Types & Sleep Module
# v9d Training Types & Sleep Module & Rest Days
app.include_router(training_types.router) # /api/training-types/*
app.include_router(admin_training_types.router) # /api/admin/training-types/*
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a)
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,62 @@
-- Migration 010: Rest Days Refactoring zu JSONB
-- v9d Phase 2a: Flexible, context-specific rest days
-- Date: 2026-03-22
-- Refactor rest_days to JSONB config for flexible rest day types
-- OLD: type VARCHAR(20) CHECK (type IN ('full_rest', 'active_recovery'))
-- NEW: rest_config JSONB with {focus, rest_from[], allows[], intensity_max}
-- Drop old type column
ALTER TABLE rest_days
DROP COLUMN IF EXISTS type;
-- Add new JSONB config column
ALTER TABLE rest_days
ADD COLUMN IF NOT EXISTS rest_config JSONB NOT NULL DEFAULT '{"focus": "mental_rest", "rest_from": [], "allows": []}'::jsonb;
-- Validation function for rest_config
CREATE OR REPLACE FUNCTION validate_rest_config(config JSONB) RETURNS BOOLEAN AS $$
BEGIN
-- Must have focus field
IF NOT (config ? 'focus') THEN
RETURN FALSE;
END IF;
-- focus must be one of the allowed values
IF NOT (config->>'focus' IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')) THEN
RETURN FALSE;
END IF;
-- rest_from must be array if present
IF (config ? 'rest_from') AND jsonb_typeof(config->'rest_from') != 'array' THEN
RETURN FALSE;
END IF;
-- allows must be array if present
IF (config ? 'allows') AND jsonb_typeof(config->'allows') != 'array' THEN
RETURN FALSE;
END IF;
-- intensity_max must be number between 1-100 if present
IF (config ? 'intensity_max') AND (
jsonb_typeof(config->'intensity_max') != 'number' OR
(config->>'intensity_max')::int < 1 OR
(config->>'intensity_max')::int > 100
) THEN
RETURN FALSE;
END IF;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- Add check constraint
ALTER TABLE rest_days
ADD CONSTRAINT valid_rest_config CHECK (validate_rest_config(rest_config));
-- Add comment for documentation
COMMENT ON COLUMN rest_days.rest_config IS 'JSONB: {focus: string, rest_from: string[], allows: string[], intensity_max?: number (1-100), note?: string}';
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Context-specific rest days (strength rest but cardio allowed, etc.)';
-- Create GIN index on rest_config for faster JSONB queries
CREATE INDEX IF NOT EXISTS idx_rest_days_config ON rest_days USING GIN (rest_config);

View File

@ -0,0 +1,17 @@
-- Migration 011: Allow Multiple Rest Days per Date
-- v9d Phase 2a: Support for multi-dimensional rest (development routes)
-- Date: 2026-03-22
-- Remove UNIQUE constraint to allow multiple rest day types per date
-- Use Case: Muscle recovery + Mental rest on same day
-- Future: Development routes (Conditioning, Strength, Coordination, Mental, Mobility, Technique)
ALTER TABLE rest_days
DROP CONSTRAINT IF EXISTS unique_rest_day_per_profile;
-- Add index for efficient queries (profile_id, date)
CREATE INDEX IF NOT EXISTS idx_rest_days_profile_date_multi
ON rest_days(profile_id, date DESC);
-- Comment for documentation
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Multi-dimensional rest days - multiple entries per date allowed for different development routes (muscle, cardio, mental, coordination, technique)';

View File

@ -0,0 +1,34 @@
-- Migration 012: Unique constraint on (profile_id, date, focus)
-- v9d Phase 2a: Prevent duplicate rest day types per date
-- Date: 2026-03-22
-- Add focus column (extracted from rest_config for performance + constraints)
ALTER TABLE rest_days
ADD COLUMN IF NOT EXISTS focus VARCHAR(20);
-- Populate from existing JSONB data
UPDATE rest_days
SET focus = rest_config->>'focus'
WHERE focus IS NULL;
-- Make NOT NULL (safe because we just populated all rows)
ALTER TABLE rest_days
ALTER COLUMN focus SET NOT NULL;
-- Add CHECK constraint for valid focus values
ALTER TABLE rest_days
ADD CONSTRAINT valid_focus CHECK (
focus IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')
);
-- Add UNIQUE constraint: Same profile + date + focus = duplicate
ALTER TABLE rest_days
ADD CONSTRAINT unique_rest_day_per_focus
UNIQUE (profile_id, date, focus);
-- Add index for efficient queries by focus
CREATE INDEX IF NOT EXISTS idx_rest_days_focus
ON rest_days(focus);
-- Comment for documentation
COMMENT ON COLUMN rest_days.focus IS 'Extracted from rest_config.focus for performance and constraints. Prevents duplicate rest day types per date.';

View File

@ -7,6 +7,7 @@ import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin
@ -103,7 +104,7 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi
data.description_de,
data.description_en,
data.sort_order,
abilities_json
Json(abilities_json)
))
new_id = cur.fetchone()['id']
@ -153,7 +154,7 @@ def update_training_type(
values.append(data.sort_order)
if data.abilities is not None:
updates.append("abilities = %s")
values.append(data.abilities)
values.append(Json(data.abilities))
if not updates:
raise HTTPException(400, "No fields to update")

View File

@ -9,7 +9,7 @@ import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Header, HTTPException, Depends
from fastapi import APIRouter, UploadFile, File, Form, Header, HTTPException, Depends
from fastapi.responses import FileResponse
import aiofiles
@ -26,7 +26,7 @@ PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
@router.post("")
async def upload_photo(file: UploadFile=File(...), date: str="",
async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Upload progress photo."""
pid = get_pid(x_profile_id)
@ -50,15 +50,19 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
ext = Path(file.filename).suffix or '.jpg'
path = PHOTOS_DIR / f"{fid}{ext}"
async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
# Convert empty string to NULL for date field
photo_date = date if date and date.strip() else None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(fid,pid,date,str(path)))
(fid,pid,photo_date,str(path)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos')
return {"id":fid,"date":date}
return {"id":fid,"date":photo_date}
@router.get("/{fid}")

View File

@ -0,0 +1,368 @@
"""
Rest Days Endpoints for Mitai Jinkendo
Context-specific rest days with flexible JSONB configuration.
"""
import logging
from typing import Optional, Literal
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Header
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from psycopg2.errors import UniqueViolation
from db import get_db, get_cursor, r2d
from auth import require_auth
from routers.profiles import get_pid
router = APIRouter(prefix="/api/rest-days", tags=["rest-days"])
logger = logging.getLogger(__name__)
# ── Models ────────────────────────────────────────────────────────────────────
class RestConfig(BaseModel):
focus: Literal['muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury']
rest_from: list[str] = Field(default_factory=list, description="Training type IDs to avoid")
allows: list[str] = Field(default_factory=list, description="Allowed activity type IDs")
intensity_max: Optional[int] = Field(None, ge=1, le=100, description="Max HR% for allowed activities")
note: str = ""
class RestDayCreate(BaseModel):
date: str # YYYY-MM-DD
rest_config: RestConfig
note: str = ""
class RestDayUpdate(BaseModel):
date: Optional[str] = None
rest_config: Optional[RestConfig] = None
note: Optional[str] = None
class ActivityConflictCheck(BaseModel):
date: str
activity_type: str
# ── CRUD Endpoints ────────────────────────────────────────────────────────────
@router.get("")
def list_rest_days(
limit: int = 90,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""List rest days for current profile (last N days)."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, profile_id, date, rest_config, note, created_at
FROM rest_days
WHERE profile_id = %s
ORDER BY date DESC
LIMIT %s
""",
(pid, limit)
)
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_rest_day(
data: RestDayCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Create rest day with JSONB config. Upserts by date."""
pid = get_pid(x_profile_id)
# Validate date format
try:
datetime.strptime(data.date, '%Y-%m-%d')
except ValueError:
raise HTTPException(400, "Invalid date format. Use YYYY-MM-DD")
# Convert RestConfig to dict for JSONB storage
config_dict = data.rest_config.model_dump()
focus = data.rest_config.focus
try:
with get_db() as conn:
cur = get_cursor(conn)
# Insert (multiple entries per date allowed, but not same focus)
cur.execute(
"""
INSERT INTO rest_days (profile_id, date, focus, rest_config, note, created_at)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id, profile_id, date, focus, rest_config, note, created_at
""",
(pid, data.date, focus, Json(config_dict), data.note)
)
result = cur.fetchone()
return r2d(result)
except UniqueViolation:
# User-friendly error for duplicate focus
focus_labels = {
'muscle_recovery': 'Muskelregeneration',
'cardio_recovery': 'Cardio-Erholung',
'mental_rest': 'Mentale Erholung',
'deload': 'Deload',
'injury': 'Verletzungspause',
}
focus_label = focus_labels.get(focus, focus)
raise HTTPException(
400,
f"Du hast bereits einen Ruhetag '{focus_label}' für {data.date}. Bitte wähle einen anderen Typ oder lösche den bestehenden Eintrag."
)
@router.get("/{rest_day_id}")
def get_rest_day(
rest_day_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Get single rest day by ID."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, profile_id, date, rest_config, note, created_at
FROM rest_days
WHERE id = %s AND profile_id = %s
""",
(rest_day_id, pid)
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Rest day not found")
return r2d(row)
@router.put("/{rest_day_id}")
def update_rest_day(
rest_day_id: int,
data: RestDayUpdate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Update rest day."""
pid = get_pid(x_profile_id)
# Build update fields dynamically
updates = []
values = []
if data.date:
try:
datetime.strptime(data.date, '%Y-%m-%d')
except ValueError:
raise HTTPException(400, "Invalid date format. Use YYYY-MM-DD")
updates.append("date = %s")
values.append(data.date)
if data.rest_config:
updates.append("rest_config = %s")
values.append(Json(data.rest_config.model_dump()))
# Also update focus column if config changed
updates.append("focus = %s")
values.append(data.rest_config.focus)
if data.note is not None:
updates.append("note = %s")
values.append(data.note)
if not updates:
raise HTTPException(400, "No fields to update")
values.extend([rest_day_id, pid])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
UPDATE rest_days
SET {', '.join(updates)}
WHERE id = %s AND profile_id = %s
RETURNING id, profile_id, date, rest_config, note, created_at
""",
values
)
result = cur.fetchone()
if not result:
raise HTTPException(404, "Rest day not found")
return r2d(result)
@router.delete("/{rest_day_id}")
def delete_rest_day(
rest_day_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Delete rest day."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM rest_days WHERE id = %s AND profile_id = %s RETURNING id",
(rest_day_id, pid)
)
result = cur.fetchone()
if not result:
raise HTTPException(404, "Rest day not found")
return {"deleted": True, "id": result['id']}
# ── Stats & Validation ────────────────────────────────────────────────────────
@router.get("/stats")
def get_rest_days_stats(
weeks: int = 4,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Get rest day statistics (count per week, focus distribution)."""
pid = get_pid(x_profile_id)
cutoff_date = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
with get_db() as conn:
cur = get_cursor(conn)
# Total count
cur.execute(
"""
SELECT COUNT(*) as total
FROM rest_days
WHERE profile_id = %s AND date >= %s
""",
(pid, cutoff_date)
)
total = cur.fetchone()['total']
# Count by focus type
cur.execute(
"""
SELECT
rest_config->>'focus' as focus,
COUNT(*) as count
FROM rest_days
WHERE profile_id = %s AND date >= %s
GROUP BY rest_config->>'focus'
ORDER BY count DESC
""",
(pid, cutoff_date)
)
by_focus = [r2d(r) for r in cur.fetchall()]
# Count by week (ISO week number)
cur.execute(
"""
SELECT
EXTRACT(YEAR FROM date) as year,
EXTRACT(WEEK FROM date) as week,
COUNT(*) as count
FROM rest_days
WHERE profile_id = %s AND date >= %s
GROUP BY year, week
ORDER BY year DESC, week DESC
""",
(pid, cutoff_date)
)
by_week = [r2d(r) for r in cur.fetchall()]
return {
"total_rest_days": total,
"weeks_analyzed": weeks,
"by_focus": by_focus,
"by_week": by_week
}
@router.post("/validate-activity")
def validate_activity(
data: ActivityConflictCheck,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""
Check if activity conflicts with rest day configuration.
Returns:
- conflict: bool
- severity: 'warning' | 'info' | 'none'
- message: str
"""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT rest_config
FROM rest_days
WHERE profile_id = %s AND date = %s
""",
(pid, data.date)
)
row = cur.fetchone()
if not row:
return {"conflict": False, "severity": "none", "message": ""}
config = row['rest_config']
# Check if activity is in rest_from
if data.activity_type in config.get('rest_from', []):
focus_labels = {
'muscle_recovery': 'Muskelregeneration',
'cardio_recovery': 'Cardio-Erholung',
'mental_rest': 'Mentale Erholung',
'deload': 'Deload',
'injury': 'Verletzungspause'
}
focus_label = focus_labels.get(config.get('focus'), 'Ruhetag')
return {
"conflict": True,
"severity": "warning",
"message": f"Ruhetag ({focus_label}) {data.activity_type} sollte pausiert werden. Trotzdem erfassen?"
}
# Check if activity is allowed
allows_list = config.get('allows', [])
if allows_list and data.activity_type not in allows_list:
return {
"conflict": True,
"severity": "info",
"message": f"Aktivität nicht in erlaubten Aktivitäten. Heute: {', '.join(allows_list) or 'Keine'}."
}
# Check intensity_max (if provided in request)
intensity_max = config.get('intensity_max')
if intensity_max:
return {
"conflict": False,
"severity": "info",
"message": f"Erlaubt bei max. {intensity_max}% HFmax."
}
return {"conflict": False, "severity": "none", "message": ""}

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

@ -0,0 +1,120 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const FOCUS_ICONS = {
muscle_recovery: '💪',
cardio_recovery: '🏃',
mental_rest: '🧘',
deload: '📉',
injury: '🩹',
}
const FOCUS_LABELS = {
muscle_recovery: 'Muskelregeneration',
cardio_recovery: 'Cardio-Erholung',
mental_rest: 'Mentale Erholung',
deload: 'Deload',
injury: 'Verletzungspause',
}
const FOCUS_COLORS = {
muscle_recovery: '#D85A30',
cardio_recovery: '#378ADD',
mental_rest: '#7B68EE',
deload: '#E67E22',
injury: '#E74C3C',
}
export default function RestDaysWidget() {
const [todayRestDays, setTodayRestDays] = useState([])
const [loading, setLoading] = useState(true)
const nav = useNavigate()
useEffect(() => {
loadTodayRestDays()
}, [])
const loadTodayRestDays = async () => {
try {
const today = dayjs().format('YYYY-MM-DD')
const allRestDays = await api.listRestDays(7) // Last 7 days
const todayOnly = allRestDays.filter(d => d.date === today)
setTodayRestDays(todayOnly)
} catch (err) {
console.error('Failed to load rest days:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="card">
<div className="card-title">🛌 Ruhetage</div>
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text3)' }}>
Lädt...
</div>
</div>
)
}
return (
<div className="card">
<div className="card-title">🛌 Ruhetage</div>
{todayRestDays.length === 0 ? (
<div style={{ padding: '12px 0', color: 'var(--text3)', fontSize: 13 }}>
Heute kein Ruhetag geplant
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{todayRestDays.map(day => {
const focus = day.rest_config?.focus || 'mental_rest'
const icon = FOCUS_ICONS[focus] || '📅'
const label = FOCUS_LABELS[focus] || focus
const color = FOCUS_COLORS[focus] || '#888'
return (
<div
key={day.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 8,
background: `${color}14`,
border: `1px solid ${color}`,
}}
>
<div style={{ fontSize: 20 }}>{icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, color }}>
{label}
</div>
{day.note && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{day.note}
</div>
)}
</div>
</div>
)
})}
</div>
)}
<button
className="btn btn-secondary btn-full"
style={{ marginTop: 12, fontSize: 13 }}
onClick={() => nav('/rest-days')}
>
Ruhetage verwalten
</button>
</div>
)
}

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

@ -12,6 +12,7 @@ import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import SleepWidget from '../components/SleepWidget'
import RestDaysWidget from '../components/RestDaysWidget'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import dayjs from 'dayjs'
@ -477,6 +478,11 @@ export default function Dashboard() {
<SleepWidget/>
</div>
{/* Rest Days Widget */}
<div style={{marginBottom:16}}>
<RestDaysWidget/>
</div>
{/* Training Type Distribution */}
{activities.length > 0 && (
<div className="card section-gap" style={{marginBottom:16}}>

View File

@ -0,0 +1,507 @@
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)
const isToday = day.date === dayjs().format('YYYY-MM-DD')
return (
<div key={day.id} className="card" style={{
marginBottom: 8,
border: isToday ? '2px solid var(--accent)' : undefined,
background: isToday ? 'var(--accent)08' : undefined,
}}>
{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={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{dayjs(day.date).format('DD. MMMM YYYY')}
</div>
{isToday && (
<span style={{
padding: '2px 8px',
borderRadius: 4,
background: 'var(--accent)',
color: 'white',
fontSize: 11,
fontWeight: 600,
}}>
HEUTE
</span>
)}
</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,
}}>
<span style={{ fontSize: 14 }}>{preset?.icon || '📅'}</span>
{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>
)
}

View File

@ -15,7 +15,16 @@ function hdrs(extra={}) {
async function req(path, opts={}) {
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
if (!res.ok) { const err=await res.text(); throw new Error(err) }
if (!res.ok) {
const err = await res.text()
// Try to parse JSON error with detail field
try {
const parsed = JSON.parse(err)
throw new Error(parsed.detail || err)
} catch {
throw new Error(err)
}
}
return res.json()
}
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
@ -230,4 +239,13 @@ export const api = {
fd.append('file', file)
return req('/sleep/import/apple-health', {method:'POST', body:fd})
},
// Rest Days (v9d Phase 2a)
listRestDays: (l=90) => req(`/rest-days?limit=${l}`),
createRestDay: (d) => req('/rest-days', json(d)),
getRestDay: (id) => req(`/rest-days/${id}`),
updateRestDay: (id,d) => req(`/rest-days/${id}`, jput(d)),
deleteRestDay: (id) => req(`/rest-days/${id}`, {method:'DELETE'}),
getRestDaysStats: (weeks=4) => req(`/rest-days/stats?weeks=${weeks}`),
validateActivity: (date, activityType) => req('/rest-days/validate-activity', json({date, activity_type: activityType})),
}