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>
352 lines
10 KiB
Python
352 lines
10 KiB
Python
"""
|
||
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": ""}
|