mitai-jinkendo/backend/routers/rest_days.py
Lars b63d15fd02
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
feat: flexible rest days system with JSONB config (v9d Phase 2a)
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 <noreply@anthropic.com>
2026-03-22 16:20:52 +01:00

352 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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