mitai-jinkendo/backend/routers/rest_days.py

369 lines
11 KiB
Python
Raw Normal View History

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