From b63d15fd0241a30f17d989b23d64e1f666d71626 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 16:20:52 +0100 Subject: [PATCH] feat: flexible rest days system with JSONB config (v9d Phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/main.py | 5 +- backend/migrations/010_rest_days_jsonb.sql | 62 ++++ backend/routers/rest_days.py | 351 +++++++++++++++++++++ frontend/src/utils/api.js | 9 + 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/010_rest_days_jsonb.sql create mode 100644 backend/routers/rest_days.py diff --git a/backend/main.py b/backend/main.py index 229ea16..fba2846 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts from routers import admin, stats, exportdata, importdata 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 admin_activity_mappings, sleep +from routers import admin_activity_mappings, sleep, rest_days # ── App Configuration ───────────────────────────────────────────────────────── 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(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(admin_training_types.router) # /api/admin/training-types/* app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/* app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b) +app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a) # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/010_rest_days_jsonb.sql b/backend/migrations/010_rest_days_jsonb.sql new file mode 100644 index 0000000..40d62d5 --- /dev/null +++ b/backend/migrations/010_rest_days_jsonb.sql @@ -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); diff --git a/backend/routers/rest_days.py b/backend/routers/rest_days.py new file mode 100644 index 0000000..3177a0e --- /dev/null +++ b/backend/routers/rest_days.py @@ -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": ""} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c2b9638..360edca 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -230,4 +230,13 @@ export const api = { fd.append('file', file) 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})), }