diff --git a/backend/main.py b/backend/main.py index 229ea16..fba2846 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/migrations/010_rest_days_jsonb.sql b/backend/migrations/010_rest_days_jsonb.sql new file mode 100644 index 0000000..40d62d5 --- /dev/null +++ b/backend/migrations/010_rest_days_jsonb.sql @@ -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); diff --git a/backend/migrations/011_allow_multiple_rest_days_per_date.sql b/backend/migrations/011_allow_multiple_rest_days_per_date.sql new file mode 100644 index 0000000..81e269a --- /dev/null +++ b/backend/migrations/011_allow_multiple_rest_days_per_date.sql @@ -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)'; diff --git a/backend/migrations/012_rest_days_unique_focus.sql b/backend/migrations/012_rest_days_unique_focus.sql new file mode 100644 index 0000000..f7f19e2 --- /dev/null +++ b/backend/migrations/012_rest_days_unique_focus.sql @@ -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.'; diff --git a/backend/routers/admin_training_types.py b/backend/routers/admin_training_types.py index f26db55..49899ed 100644 --- a/backend/routers/admin_training_types.py +++ b/backend/routers/admin_training_types.py @@ -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") diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 6fc06f0..daa5ca9 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -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}") diff --git a/backend/routers/rest_days.py b/backend/routers/rest_days.py new file mode 100644 index 0000000..6d2fde4 --- /dev/null +++ b/backend/routers/rest_days.py @@ -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": ""} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3df5521..a83ecf1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/RestDaysWidget.jsx b/frontend/src/components/RestDaysWidget.jsx new file mode 100644 index 0000000..d04ff90 --- /dev/null +++ b/frontend/src/components/RestDaysWidget.jsx @@ -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 ( +
+
🛌 Ruhetage
+
+ Lädt... +
+
+ ) + } + + return ( +
+
🛌 Ruhetage
+ + {todayRestDays.length === 0 ? ( +
+ Heute kein Ruhetag geplant +
+ ) : ( +
+ {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 ( +
+
{icon}
+
+
+ {label} +
+ {day.note && ( +
+ {day.note} +
+ )} +
+
+ ) + })} +
+ )} + + +
+ ) +} diff --git a/frontend/src/pages/CaptureHub.jsx b/frontend/src/pages/CaptureHub.jsx index 1ce7dcd..cdfb45f 100644 --- a/frontend/src/pages/CaptureHub.jsx +++ b/frontend/src/pages/CaptureHub.jsx @@ -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', diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 7c86135..35462f5 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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() { + {/* Rest Days Widget */} +
+ +
+ {/* Training Type Distribution */} {activities.length > 0 && (
diff --git a/frontend/src/pages/RestDaysPage.jsx b/frontend/src/pages/RestDaysPage.jsx new file mode 100644 index 0000000..4f793f2 --- /dev/null +++ b/frontend/src/pages/RestDaysPage.jsx @@ -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 ( +
+

Ruhetage

+ + {/* Toast Notification */} + {toast && ( +
+ {toast} +
+ )} + + {/* New Entry Form */} + {!showForm ? ( + + ) : ( +
+
Neuer Ruhetag
+ + {/* Date */} +
+ + setFormData(f => ({ ...f, date: e.target.value }))} + /> + +
+ + {/* Presets */} +
+ +
+ {PRESETS.map(preset => ( + + ))} +
+
+ + {/* Note */} +
+ + setFormData(f => ({ ...f, note: e.target.value }))} + /> + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {/* List */} +
+
+ Verlauf ({restDays.length}) +
+ + {restDays.length === 0 && ( +

Noch keine Ruhetage erfasst.

+ )} + + {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 ( +
+ {isEditing ? ( +
+
+ {dayjs(day.date).format('DD. MMMM YYYY')} +
+
+ + setEditing(d => ({ ...d, note: e.target.value }))} + /> + +
+
+ + +
+
+ ) : ( +
+
+
+
+ {dayjs(day.date).format('DD. MMMM YYYY')} +
+ {isToday && ( + + HEUTE + + )} +
+
+ + +
+
+ + {/* Preset Badge */} +
+ {preset?.icon || '📅'} + {FOCUS_LABELS[focus] || focus} +
+ + {/* Note */} + {day.note && ( +

+ "{day.note}" +

+ )} + + {/* Details (collapsible, optional for later) */} + {day.rest_config && ( +
+ {day.rest_config.rest_from?.length > 0 && ( +
+ Pausiert: {day.rest_config.rest_from.join(', ')} +
+ )} + {day.rest_config.allows?.length > 0 && ( +
+ Erlaubt: {day.rest_config.allows.join(', ')} +
+ )} + {day.rest_config.intensity_max && ( +
+ Max Intensität: {day.rest_config.intensity_max}% HFmax +
+ )} +
+ )} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c2b9638..cf1deb1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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})), }