From ef27660fc8253021a6977c255545cf6fdafc2d4e Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 14:25:27 +0100 Subject: [PATCH 01/10] fix: photo upload with empty date string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - Photo upload with empty date parameter (date='') - PostgreSQL rejects empty string for DATE field - Error: "invalid input syntax for type date: ''" - Occurred when saving circumference entry with only photo Fix: - Convert empty string to NULL before INSERT - Check: date if date and date.strip() else None - NULL is valid for optional date field Test case: - Circumference entry with only photo → should work now - Photo without date → stored with date=NULL ✓ Co-Authored-By: Claude Opus 4.6 --- backend/routers/photos.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 6fc06f0..24a42f7 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -50,10 +50,14 @@ 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') -- 2.43.0 From 0278a8e4a6ac4608485a0d8f278ccd297ed16b5a Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 14:33:01 +0100 Subject: [PATCH 02/10] fix: photo upload date parameter parsing Problem: Photos were always getting NULL date instead of form date, causing frontend to fallback to created timestamp (today). Root cause: FastAPI requires Form() wrapper for form fields when mixing with File() parameters. Without it, the date parameter was treated as query parameter and always received empty string. Solution: - Import Form from fastapi - Change date parameter from str="" to str=Form("") - Return photo_date instead of date in response (consistency) Now photos correctly use the date from the upload form and can be backdated when uploading later. Co-Authored-By: Claude Opus 4.6 --- backend/routers/photos.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 24a42f7..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) @@ -62,7 +62,7 @@ async def upload_photo(file: UploadFile=File(...), date: str="", # Phase 2: Increment usage counter increment_feature_usage(pid, 'photos') - return {"id":fid,"date":date} + return {"id":fid,"date":photo_date} @router.get("/{fid}") -- 2.43.0 From b63d15fd0241a30f17d989b23d64e1f666d71626 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 16:20:52 +0100 Subject: [PATCH 03/10] feat: flexible rest days system with JSONB config (v9d Phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEM: Simple full_rest/active_recovery model doesn't support context-specific rest days (e.g., strength rest but cardio allowed). SOLUTION: JSONB-based flexible rest day configuration. ## Changes: **Migration 010:** - Refactor rest_days.type → rest_config JSONB - Schema: {focus, rest_from[], allows[], intensity_max} - Validation function with check constraint - GIN index for performant JSONB queries **Backend (routers/rest_days.py):** - CRUD: list, create (upsert by date), get, update, delete - Stats: count per week, focus distribution - Validation: check activity conflicts with rest day config **Frontend (api.js):** - 7 new methods: listRestDays, createRestDay, updateRestDay, deleteRestDay, getRestDaysStats, validateActivity **Integration:** - Router registered in main.py - Ready for weekly planning validation rules ## Next Steps: - Frontend UI (RestDaysPage with Quick/Custom mode) - Activity conflict warnings - Dashboard widget Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 +- backend/migrations/010_rest_days_jsonb.sql | 62 ++++ backend/routers/rest_days.py | 351 +++++++++++++++++++++ frontend/src/utils/api.js | 9 + 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/010_rest_days_jsonb.sql create mode 100644 backend/routers/rest_days.py 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/routers/rest_days.py b/backend/routers/rest_days.py new file mode 100644 index 0000000..3177a0e --- /dev/null +++ b/backend/routers/rest_days.py @@ -0,0 +1,351 @@ +""" +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 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() + + with get_db() as conn: + cur = get_cursor(conn) + + # Upsert by (profile_id, date) + cur.execute( + """ + INSERT INTO rest_days (profile_id, date, rest_config, note, created_at) + VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (profile_id, date) + DO UPDATE SET + rest_config = EXCLUDED.rest_config, + note = EXCLUDED.note + RETURNING id, profile_id, date, rest_config, note, created_at + """, + (pid, data.date, config_dict, data.note) + ) + + result = cur.fetchone() + return r2d(result) + + +@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(data.rest_config.model_dump()) + + 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/utils/api.js b/frontend/src/utils/api.js index c2b9638..360edca 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -230,4 +230,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})), } -- 2.43.0 From c265ab12459c857024845494daf926f0ddf89942 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 16:33:32 +0100 Subject: [PATCH 04/10] feat: RestDaysPage UI with Quick Mode presets (v9d Phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.jsx | 2 + frontend/src/pages/CaptureHub.jsx | 7 + frontend/src/pages/RestDaysPage.jsx | 487 ++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 frontend/src/pages/RestDaysPage.jsx 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/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/RestDaysPage.jsx b/frontend/src/pages/RestDaysPage.jsx new file mode 100644 index 0000000..01728ab --- /dev/null +++ b/frontend/src/pages/RestDaysPage.jsx @@ -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 ( +
+

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) + + return ( +
+ {isEditing ? ( +
+
+ {dayjs(day.date).format('DD. MMMM YYYY')} +
+
+ + setEditing(d => ({ ...d, note: e.target.value }))} + /> + +
+
+ + +
+
+ ) : ( +
+
+
+ {dayjs(day.date).format('DD. MMMM YYYY')} +
+
+ + +
+
+ + {/* 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 +
+ )} +
+ )} +
+ )} +
+ ) + })} +
+
+ ) +} -- 2.43.0 From 7d627cf128aa231a0ec978cd3429a0971405b9ce Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 16:38:39 +0100 Subject: [PATCH 05/10] fix: wrap rest_config dict with Json() for psycopg2 JSONB insert Error: psycopg2.ProgrammingError: can't adapt type 'dict' Solution: Import psycopg2.extras.Json and wrap config_dict Changes: - Import Json from psycopg2.extras - Wrap config_dict with Json() in INSERT - Wrap config_dict with Json() in UPDATE Co-Authored-By: Claude Opus 4.6 --- backend/routers/rest_days.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/routers/rest_days.py b/backend/routers/rest_days.py index 3177a0e..3fef4cd 100644 --- a/backend/routers/rest_days.py +++ b/backend/routers/rest_days.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Depends, Header from pydantic import BaseModel, Field +from psycopg2.extras import Json from db import get_db, get_cursor, r2d from auth import require_auth @@ -103,7 +104,7 @@ def create_rest_day( note = EXCLUDED.note RETURNING id, profile_id, date, rest_config, note, created_at """, - (pid, data.date, config_dict, data.note) + (pid, data.date, Json(config_dict), data.note) ) result = cur.fetchone() @@ -161,7 +162,7 @@ def update_rest_day( if data.rest_config: updates.append("rest_config = %s") - values.append(data.rest_config.model_dump()) + values.append(Json(data.rest_config.model_dump())) if data.note is not None: updates.append("note = %s") -- 2.43.0 From 6916e5b8088f6966aaadddba1428dc8e7043bb98 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 16:51:09 +0100 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20multi-dimensional=20rest=20days?= =?UTF-8?q?=20+=20development=20routes=20architecture=20(v9d=20=E2=86=92?= =?UTF-8?q?=20v9e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes: **Frontend:** - Fix double icon in rest day list (removed icons from FOCUS_LABELS) - Icon now shows once with proper styling **Migration 011:** - Remove UNIQUE constraint (profile_id, date) from rest_days - Allow multiple rest day types per date - Use case: Muscle recovery + Mental rest same day **Architecture: Development Routes** New document: `.claude/docs/functional/DEVELOPMENT_ROUTES.md` 6 Independent Development Routes: - 💪 Kraft (Strength): Muscle, power, HIIT - 🏃 Kondition (Conditioning): Cardio, endurance, VO2max - 🧘 Mental: Stress, focus, competition readiness - 🤸 Koordination (Coordination): Balance, agility, technique - 🧘‍♂️ Mobilität (Mobility): Flexibility, ROM, fascia - 🎯 Technik (Technique): Sport-specific skills Each route has: - Independent rest requirements - Independent training plans - Independent progress tracking - Independent goals & habits **Future (v9e):** - Route-based weekly planning - Multi-route conflict validation - Auto-rest on poor recovery - Route balance analysis (KI) **Future (v9g):** - Habits per route (route_habits table) - Streak tracking per route - Dashboard route-habits widget **Backlog Updated:** - v9d: Rest days ✅ (in testing) - v9e: Development Routes & Weekly Planning (new) - v9g: Habits per Route (extended) Co-Authored-By: Claude Opus 4.6 --- .../011_allow_multiple_rest_days_per_date.sql | 17 +++++++++++++++++ frontend/src/pages/RestDaysPage.jsx | 13 +++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/011_allow_multiple_rest_days_per_date.sql 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/frontend/src/pages/RestDaysPage.jsx b/frontend/src/pages/RestDaysPage.jsx index 01728ab..d8ad303 100644 --- a/frontend/src/pages/RestDaysPage.jsx +++ b/frontend/src/pages/RestDaysPage.jsx @@ -62,11 +62,11 @@ const PRESETS = [ ] const FOCUS_LABELS = { - muscle_recovery: '💪 Muskelregeneration', - cardio_recovery: '🏃 Cardio-Erholung', - mental_rest: '🧘 Mentale Erholung', - deload: '📉 Deload', - injury: '🩹 Verletzungspause', + muscle_recovery: 'Muskelregeneration', + cardio_recovery: 'Cardio-Erholung', + mental_rest: 'Mentale Erholung', + deload: 'Deload', + injury: 'Verletzungspause', } export default function RestDaysPage() { @@ -434,7 +434,8 @@ export default function RestDaysPage() { color: preset?.color || '#888', marginBottom: day.note ? 8 : 0, }}> - {preset?.icon || '📅'} {FOCUS_LABELS[focus] || focus} + {preset?.icon || '📅'} + {FOCUS_LABELS[focus] || focus} {/* Note */} -- 2.43.0 From f2e2aff17f231d31adeb1377355a261833657a2d Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 17:05:06 +0100 Subject: [PATCH 07/10] fix: remove ON CONFLICT clause after constraint removal Migration 011 removed UNIQUE constraint (profile_id, date) to allow multiple rest days per date, but INSERT still used ON CONFLICT. Error: psycopg2.errors.InvalidColumnReference: there is no unique or exclusion constraint matching the ON CONFLICT specification Solution: Remove ON CONFLICT clause, use plain INSERT. Multiple entries per date now allowed. Co-Authored-By: Claude Opus 4.6 --- backend/routers/rest_days.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/routers/rest_days.py b/backend/routers/rest_days.py index 3fef4cd..490de6a 100644 --- a/backend/routers/rest_days.py +++ b/backend/routers/rest_days.py @@ -93,15 +93,11 @@ def create_rest_day( with get_db() as conn: cur = get_cursor(conn) - # Upsert by (profile_id, date) + # Insert (multiple entries per date allowed) cur.execute( """ INSERT INTO rest_days (profile_id, date, rest_config, note, created_at) VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) - ON CONFLICT (profile_id, date) - DO UPDATE SET - rest_config = EXCLUDED.rest_config, - note = EXCLUDED.note RETURNING id, profile_id, date, rest_config, note, created_at """, (pid, data.date, Json(config_dict), data.note) -- 2.43.0 From f87b93ce2f6bc0374fed99d7c6ed69531a269f8d Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 17:36:49 +0100 Subject: [PATCH 08/10] feat: prevent duplicate rest day types per date (Migration 012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: User can create multiple rest days of same type per date (e.g., 2x Mental Rest on 2026-03-23) - makes no sense. Solution: UNIQUE constraint on (profile_id, date, focus) ## Migration 012: - Add focus column (extracted from rest_config JSONB) - Populate from existing data - Add NOT NULL constraint - Add CHECK constraint (valid focus values) - Add UNIQUE constraint (profile_id, date, focus) - Add index for performance ## Backend: - Insert focus column alongside rest_config - Handle UniqueViolation gracefully - User-friendly error: "Du hast bereits einen Ruhetag 'Muskelregeneration' für 23.03." ## Benefits: - DB-level enforcement (clean) - Fast queries (no JSONB scan) - Clear error messages - Prevents: 2x muscle_recovery same day - Allows: muscle_recovery + mental_rest same day ✓ Co-Authored-By: Claude Opus 4.6 --- .../migrations/012_rest_days_unique_focus.sql | 34 ++++++++++++++ backend/routers/rest_days.py | 46 +++++++++++++------ 2 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/012_rest_days_unique_focus.sql 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/rest_days.py b/backend/routers/rest_days.py index 490de6a..6d2fde4 100644 --- a/backend/routers/rest_days.py +++ b/backend/routers/rest_days.py @@ -10,6 +10,7 @@ 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 @@ -89,23 +90,39 @@ def create_rest_day( # Convert RestConfig to dict for JSONB storage config_dict = data.rest_config.model_dump() + focus = data.rest_config.focus - with get_db() as conn: - cur = get_cursor(conn) + try: + with get_db() as conn: + cur = get_cursor(conn) - # Insert (multiple entries per date allowed) - cur.execute( - """ - INSERT INTO rest_days (profile_id, date, rest_config, note, created_at) - VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) - RETURNING id, profile_id, date, rest_config, note, created_at - """, - (pid, data.date, Json(config_dict), data.note) + # 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." ) - result = cur.fetchone() - return r2d(result) - @router.get("/{rest_day_id}") def get_rest_day( @@ -159,6 +176,9 @@ def update_rest_day( 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") -- 2.43.0 From 7a0b2097ae4b665083705d9166d13b5b81fe37ed Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 08:38:57 +0100 Subject: [PATCH 09/10] feat: dashboard rest days widget + today highlighting - Add RestDaysWidget component showing today's rest days with icons & colors - Integrate widget into Dashboard (above training distribution) - Highlight current day in RestDaysPage (accent border + HEUTE badge) - Fix: Improve error handling in api.js (parse JSON detail field) Part of v9d Phase 2 (Vitals & Recovery) --- frontend/src/components/RestDaysWidget.jsx | 120 +++++++++++++++++++++ frontend/src/pages/Dashboard.jsx | 6 ++ frontend/src/pages/RestDaysPage.jsx | 25 ++++- frontend/src/utils/api.js | 11 +- 4 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/RestDaysWidget.jsx 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/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 index d8ad303..4f793f2 100644 --- a/frontend/src/pages/RestDaysPage.jsx +++ b/frontend/src/pages/RestDaysPage.jsx @@ -356,9 +356,14 @@ export default function RestDaysPage() { 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 ? (
@@ -399,8 +404,22 @@ export default function RestDaysPage() { alignItems: 'center', marginBottom: 8, }}> -
- {dayjs(day.date).format('DD. MMMM YYYY')} +
+
+ {dayjs(day.date).format('DD. MMMM YYYY')} +
+ {isToday && ( + + HEUTE + + )}