""" 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": ""}