mitai-jinkendo/backend/routers/rest_days.py
Lars f87b93ce2f
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: prevent duplicate rest day types per date (Migration 012)
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 <noreply@anthropic.com>
2026-03-22 17:36:49 +01:00

369 lines
11 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 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": ""}