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>
This commit is contained in:
parent
0278a8e4a6
commit
b63d15fd02
|
|
@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts
|
||||||
from routers import admin, stats, exportdata, importdata
|
from routers import admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
||||||
from routers import admin_activity_mappings, sleep
|
from routers import admin_activity_mappings, sleep, rest_days
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||||
|
|
@ -86,11 +86,12 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
|
||||||
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
||||||
app.include_router(access_grants.router) # /api/access-grants (admin)
|
app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||||
|
|
||||||
# v9d Training Types & Sleep Module
|
# v9d Training Types & Sleep Module & Rest Days
|
||||||
app.include_router(training_types.router) # /api/training-types/*
|
app.include_router(training_types.router) # /api/training-types/*
|
||||||
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
||||||
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||||
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
|
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
|
||||||
|
app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a)
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
62
backend/migrations/010_rest_days_jsonb.sql
Normal file
62
backend/migrations/010_rest_days_jsonb.sql
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
-- Migration 010: Rest Days Refactoring zu JSONB
|
||||||
|
-- v9d Phase 2a: Flexible, context-specific rest days
|
||||||
|
-- Date: 2026-03-22
|
||||||
|
|
||||||
|
-- Refactor rest_days to JSONB config for flexible rest day types
|
||||||
|
-- OLD: type VARCHAR(20) CHECK (type IN ('full_rest', 'active_recovery'))
|
||||||
|
-- NEW: rest_config JSONB with {focus, rest_from[], allows[], intensity_max}
|
||||||
|
|
||||||
|
-- Drop old type column
|
||||||
|
ALTER TABLE rest_days
|
||||||
|
DROP COLUMN IF EXISTS type;
|
||||||
|
|
||||||
|
-- Add new JSONB config column
|
||||||
|
ALTER TABLE rest_days
|
||||||
|
ADD COLUMN IF NOT EXISTS rest_config JSONB NOT NULL DEFAULT '{"focus": "mental_rest", "rest_from": [], "allows": []}'::jsonb;
|
||||||
|
|
||||||
|
-- Validation function for rest_config
|
||||||
|
CREATE OR REPLACE FUNCTION validate_rest_config(config JSONB) RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Must have focus field
|
||||||
|
IF NOT (config ? 'focus') THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- focus must be one of the allowed values
|
||||||
|
IF NOT (config->>'focus' IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')) THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- rest_from must be array if present
|
||||||
|
IF (config ? 'rest_from') AND jsonb_typeof(config->'rest_from') != 'array' THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- allows must be array if present
|
||||||
|
IF (config ? 'allows') AND jsonb_typeof(config->'allows') != 'array' THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- intensity_max must be number between 1-100 if present
|
||||||
|
IF (config ? 'intensity_max') AND (
|
||||||
|
jsonb_typeof(config->'intensity_max') != 'number' OR
|
||||||
|
(config->>'intensity_max')::int < 1 OR
|
||||||
|
(config->>'intensity_max')::int > 100
|
||||||
|
) THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Add check constraint
|
||||||
|
ALTER TABLE rest_days
|
||||||
|
ADD CONSTRAINT valid_rest_config CHECK (validate_rest_config(rest_config));
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN rest_days.rest_config IS 'JSONB: {focus: string, rest_from: string[], allows: string[], intensity_max?: number (1-100), note?: string}';
|
||||||
|
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Context-specific rest days (strength rest but cardio allowed, etc.)';
|
||||||
|
|
||||||
|
-- Create GIN index on rest_config for faster JSONB queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rest_days_config ON rest_days USING GIN (rest_config);
|
||||||
351
backend/routers/rest_days.py
Normal file
351
backend/routers/rest_days.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
"""
|
||||||
|
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": ""}
|
||||||
|
|
@ -230,4 +230,13 @@ export const api = {
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return req('/sleep/import/apple-health', {method:'POST', body:fd})
|
return req('/sleep/import/apple-health', {method:'POST', body:fd})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Rest Days (v9d Phase 2a)
|
||||||
|
listRestDays: (l=90) => req(`/rest-days?limit=${l}`),
|
||||||
|
createRestDay: (d) => req('/rest-days', json(d)),
|
||||||
|
getRestDay: (id) => req(`/rest-days/${id}`),
|
||||||
|
updateRestDay: (id,d) => req(`/rest-days/${id}`, jput(d)),
|
||||||
|
deleteRestDay: (id) => req(`/rest-days/${id}`, {method:'DELETE'}),
|
||||||
|
getRestDaysStats: (weeks=4) => req(`/rest-days/stats?weeks=${weeks}`),
|
||||||
|
validateActivity: (date, activityType) => req('/rest-days/validate-activity', json({date, activity_type: activityType})),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user