Merge pull request 'Production Release: RestDays Widget + Trainingstyp Fix' (#16) from develop into main
Merge pull request #16: Production Release
This commit is contained in:
commit
03f4b871a9
|
|
@ -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("/")
|
||||
|
|
|
|||
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);
|
||||
17
backend/migrations/011_allow_multiple_rest_days_per_date.sql
Normal file
17
backend/migrations/011_allow_multiple_rest_days_per_date.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Migration 011: Allow Multiple Rest Days per Date
|
||||
-- v9d Phase 2a: Support for multi-dimensional rest (development routes)
|
||||
-- Date: 2026-03-22
|
||||
|
||||
-- Remove UNIQUE constraint to allow multiple rest day types per date
|
||||
-- Use Case: Muscle recovery + Mental rest on same day
|
||||
-- Future: Development routes (Conditioning, Strength, Coordination, Mental, Mobility, Technique)
|
||||
|
||||
ALTER TABLE rest_days
|
||||
DROP CONSTRAINT IF EXISTS unique_rest_day_per_profile;
|
||||
|
||||
-- Add index for efficient queries (profile_id, date)
|
||||
CREATE INDEX IF NOT EXISTS idx_rest_days_profile_date_multi
|
||||
ON rest_days(profile_id, date DESC);
|
||||
|
||||
-- Comment for documentation
|
||||
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Multi-dimensional rest days - multiple entries per date allowed for different development routes (muscle, cardio, mental, coordination, technique)';
|
||||
34
backend/migrations/012_rest_days_unique_focus.sql
Normal file
34
backend/migrations/012_rest_days_unique_focus.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- Migration 012: Unique constraint on (profile_id, date, focus)
|
||||
-- v9d Phase 2a: Prevent duplicate rest day types per date
|
||||
-- Date: 2026-03-22
|
||||
|
||||
-- Add focus column (extracted from rest_config for performance + constraints)
|
||||
ALTER TABLE rest_days
|
||||
ADD COLUMN IF NOT EXISTS focus VARCHAR(20);
|
||||
|
||||
-- Populate from existing JSONB data
|
||||
UPDATE rest_days
|
||||
SET focus = rest_config->>'focus'
|
||||
WHERE focus IS NULL;
|
||||
|
||||
-- Make NOT NULL (safe because we just populated all rows)
|
||||
ALTER TABLE rest_days
|
||||
ALTER COLUMN focus SET NOT NULL;
|
||||
|
||||
-- Add CHECK constraint for valid focus values
|
||||
ALTER TABLE rest_days
|
||||
ADD CONSTRAINT valid_focus CHECK (
|
||||
focus IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')
|
||||
);
|
||||
|
||||
-- Add UNIQUE constraint: Same profile + date + focus = duplicate
|
||||
ALTER TABLE rest_days
|
||||
ADD CONSTRAINT unique_rest_day_per_focus
|
||||
UNIQUE (profile_id, date, focus);
|
||||
|
||||
-- Add index for efficient queries by focus
|
||||
CREATE INDEX IF NOT EXISTS idx_rest_days_focus
|
||||
ON rest_days(focus);
|
||||
|
||||
-- Comment for documentation
|
||||
COMMENT ON COLUMN rest_days.focus IS 'Extracted from rest_config.focus for performance and constraints. Prevents duplicate rest day types per date.';
|
||||
|
|
@ -7,6 +7,7 @@ import logging
|
|||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
|
|
@ -103,7 +104,7 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi
|
|||
data.description_de,
|
||||
data.description_en,
|
||||
data.sort_order,
|
||||
abilities_json
|
||||
Json(abilities_json)
|
||||
))
|
||||
|
||||
new_id = cur.fetchone()['id']
|
||||
|
|
@ -153,7 +154,7 @@ def update_training_type(
|
|||
values.append(data.sort_order)
|
||||
if data.abilities is not None:
|
||||
updates.append("abilities = %s")
|
||||
values.append(data.abilities)
|
||||
values.append(Json(data.abilities))
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import logging
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Header, HTTPException, Depends
|
||||
from fastapi import APIRouter, UploadFile, File, Form, Header, HTTPException, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
import aiofiles
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
||||
|
||||
@router.post("")
|
||||
async def upload_photo(file: UploadFile=File(...), date: str="",
|
||||
async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
|
||||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Upload progress photo."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
|
@ -50,15 +50,19 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
ext = Path(file.filename).suffix or '.jpg'
|
||||
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||
async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
|
||||
|
||||
# Convert empty string to NULL for date field
|
||||
photo_date = date if date and date.strip() else None
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(fid,pid,date,str(path)))
|
||||
(fid,pid,photo_date,str(path)))
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'photos')
|
||||
|
||||
return {"id":fid,"date":date}
|
||||
return {"id":fid,"date":photo_date}
|
||||
|
||||
|
||||
@router.get("/{fid}")
|
||||
|
|
|
|||
368
backend/routers/rest_days.py
Normal file
368
backend/routers/rest_days.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"""
|
||||
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": ""}
|
||||
|
|
@ -31,6 +31,7 @@ import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
|||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -166,6 +167,7 @@ function AppShell() {
|
|||
<Route path="/caliper" element={<CaliperScreen/>}/>
|
||||
<Route path="/history" element={<History/>}/>
|
||||
<Route path="/sleep" element={<SleepPage/>}/>
|
||||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||
<Route path="/activity" element={<ActivityPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
|
|
|
|||
120
frontend/src/components/RestDaysWidget.jsx
Normal file
120
frontend/src/components/RestDaysWidget.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
const FOCUS_ICONS = {
|
||||
muscle_recovery: '💪',
|
||||
cardio_recovery: '🏃',
|
||||
mental_rest: '🧘',
|
||||
deload: '📉',
|
||||
injury: '🩹',
|
||||
}
|
||||
|
||||
const FOCUS_LABELS = {
|
||||
muscle_recovery: 'Muskelregeneration',
|
||||
cardio_recovery: 'Cardio-Erholung',
|
||||
mental_rest: 'Mentale Erholung',
|
||||
deload: 'Deload',
|
||||
injury: 'Verletzungspause',
|
||||
}
|
||||
|
||||
const FOCUS_COLORS = {
|
||||
muscle_recovery: '#D85A30',
|
||||
cardio_recovery: '#378ADD',
|
||||
mental_rest: '#7B68EE',
|
||||
deload: '#E67E22',
|
||||
injury: '#E74C3C',
|
||||
}
|
||||
|
||||
export default function RestDaysWidget() {
|
||||
const [todayRestDays, setTodayRestDays] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const nav = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadTodayRestDays()
|
||||
}, [])
|
||||
|
||||
const loadTodayRestDays = async () => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const allRestDays = await api.listRestDays(7) // Last 7 days
|
||||
const todayOnly = allRestDays.filter(d => d.date === today)
|
||||
setTodayRestDays(todayOnly)
|
||||
} catch (err) {
|
||||
console.error('Failed to load rest days:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">🛌 Ruhetage</div>
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text3)' }}>
|
||||
Lädt...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">🛌 Ruhetage</div>
|
||||
|
||||
{todayRestDays.length === 0 ? (
|
||||
<div style={{ padding: '12px 0', color: 'var(--text3)', fontSize: 13 }}>
|
||||
Heute kein Ruhetag geplant
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{todayRestDays.map(day => {
|
||||
const focus = day.rest_config?.focus || 'mental_rest'
|
||||
const icon = FOCUS_ICONS[focus] || '📅'
|
||||
const label = FOCUS_LABELS[focus] || focus
|
||||
const color = FOCUS_COLORS[focus] || '#888'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
background: `${color}14`,
|
||||
border: `1px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 20 }}>{icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color }}>
|
||||
{label}
|
||||
</div>
|
||||
{day.note && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{day.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 12, fontSize: 13 }}
|
||||
onClick={() => nav('/rest-days')}
|
||||
>
|
||||
Ruhetage verwalten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +52,13 @@ const ENTRIES = [
|
|||
to: '/sleep',
|
||||
color: '#7B68EE',
|
||||
},
|
||||
{
|
||||
icon: '🛌',
|
||||
label: 'Ruhetage',
|
||||
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
|
||||
to: '/rest-days',
|
||||
color: '#9B59B6',
|
||||
},
|
||||
{
|
||||
icon: '📖',
|
||||
label: 'Messanleitung',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import TrialBanner from '../components/TrialBanner'
|
|||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import SleepWidget from '../components/SleepWidget'
|
||||
import RestDaysWidget from '../components/RestDaysWidget'
|
||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import dayjs from 'dayjs'
|
||||
|
|
@ -477,6 +478,11 @@ export default function Dashboard() {
|
|||
<SleepWidget/>
|
||||
</div>
|
||||
|
||||
{/* Rest Days Widget */}
|
||||
<div style={{marginBottom:16}}>
|
||||
<RestDaysWidget/>
|
||||
</div>
|
||||
|
||||
{/* Training Type Distribution */}
|
||||
{activities.length > 0 && (
|
||||
<div className="card section-gap" style={{marginBottom:16}}>
|
||||
|
|
|
|||
507
frontend/src/pages/RestDaysPage.jsx
Normal file
507
frontend/src/pages/RestDaysPage.jsx
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
// Quick Mode Presets
|
||||
const PRESETS = [
|
||||
{
|
||||
id: 'muscle_recovery',
|
||||
icon: '💪',
|
||||
label: 'Kraft-Ruhetag',
|
||||
description: 'Muskelregeneration – Kraft & HIIT pausieren, Cardio erlaubt',
|
||||
color: '#D85A30',
|
||||
config: {
|
||||
focus: 'muscle_recovery',
|
||||
rest_from: ['strength', 'hiit'],
|
||||
allows: ['cardio_low', 'meditation', 'mobility', 'walk'],
|
||||
intensity_max: 60,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'cardio_recovery',
|
||||
icon: '🏃',
|
||||
label: 'Cardio-Ruhetag',
|
||||
description: 'Ausdauererholung – Cardio pausieren, Kraft & Mobility erlaubt',
|
||||
color: '#378ADD',
|
||||
config: {
|
||||
focus: 'cardio_recovery',
|
||||
rest_from: ['cardio', 'cardio_low', 'cardio_high'],
|
||||
allows: ['strength', 'mobility', 'meditation'],
|
||||
intensity_max: 70,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mental_rest',
|
||||
icon: '🧘',
|
||||
label: 'Entspannungstag',
|
||||
description: 'Mentale Erholung – Nur Meditation & Spaziergang',
|
||||
color: '#7B68EE',
|
||||
config: {
|
||||
focus: 'mental_rest',
|
||||
rest_from: ['strength', 'cardio', 'hiit', 'power'],
|
||||
allows: ['meditation', 'walk'],
|
||||
intensity_max: 40,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'deload',
|
||||
icon: '📉',
|
||||
label: 'Deload',
|
||||
description: 'Reduzierte Intensität – Alles erlaubt, max 70% Last',
|
||||
color: '#E67E22',
|
||||
config: {
|
||||
focus: 'deload',
|
||||
rest_from: [],
|
||||
allows: ['strength', 'cardio', 'mobility', 'meditation'],
|
||||
intensity_max: 70,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const FOCUS_LABELS = {
|
||||
muscle_recovery: 'Muskelregeneration',
|
||||
cardio_recovery: 'Cardio-Erholung',
|
||||
mental_rest: 'Mentale Erholung',
|
||||
deload: 'Deload',
|
||||
injury: 'Verletzungspause',
|
||||
}
|
||||
|
||||
export default function RestDaysPage() {
|
||||
const [restDays, setRestDays] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
preset: null,
|
||||
note: '',
|
||||
})
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadRestDays()
|
||||
}, [])
|
||||
|
||||
const loadRestDays = async () => {
|
||||
try {
|
||||
const data = await api.listRestDays(90)
|
||||
setRestDays(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load rest days:', err)
|
||||
setError('Fehler beim Laden der Ruhetage')
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (message, duration = 2000) => {
|
||||
setToast(message)
|
||||
setTimeout(() => setToast(null), duration)
|
||||
}
|
||||
|
||||
const handlePresetSelect = (preset) => {
|
||||
setFormData(f => ({ ...f, preset: preset.id }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.preset) {
|
||||
setError('Bitte wähle einen Ruhetag-Typ')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const preset = PRESETS.find(p => p.id === formData.preset)
|
||||
await api.createRestDay({
|
||||
date: formData.date,
|
||||
rest_config: preset.config,
|
||||
note: formData.note,
|
||||
})
|
||||
|
||||
showToast('✓ Ruhetag gespeichert')
|
||||
await loadRestDays()
|
||||
setShowForm(false)
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
preset: null,
|
||||
note: '',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Ruhetag löschen?')) return
|
||||
|
||||
try {
|
||||
await api.deleteRestDay(id)
|
||||
showToast('✓ Gelöscht')
|
||||
await loadRestDays()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message || 'Fehler beim Löschen')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (day) => {
|
||||
setEditing({
|
||||
...day,
|
||||
note: day.note || '',
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.updateRestDay(editing.id, {
|
||||
note: editing.note,
|
||||
})
|
||||
showToast('✓ Aktualisiert')
|
||||
setEditing(null)
|
||||
await loadRestDays()
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err)
|
||||
setError(err.message || 'Fehler beim Aktualisieren')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Ruhetage</h1>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 70,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
zIndex: 1000,
|
||||
animation: 'slideDown 0.3s ease',
|
||||
}}>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Entry Form */}
|
||||
{!showForm ? (
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
+ Ruhetag erfassen
|
||||
</button>
|
||||
) : (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="card-title">Neuer Ruhetag</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
style={{ width: 140 }}
|
||||
value={formData.date}
|
||||
onChange={e => setFormData(f => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="form-label" style={{ marginBottom: 8 }}>Typ wählen:</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: formData.preset === preset.id
|
||||
? `2px solid ${preset.color}`
|
||||
: '1.5px solid var(--border)',
|
||||
background: formData.preset === preset.id
|
||||
? `${preset.color}14`
|
||||
: 'var(--surface)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 24,
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
background: `${preset.color}22`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{preset.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: formData.preset === preset.id ? preset.color : 'var(--text1)',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
{formData.preset === preset.id && (
|
||||
<Check size={18} style={{ color: preset.color, flexShrink: 0 }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="form-row" style={{ marginTop: 16 }}>
|
||||
<label className="form-label">Notiz</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="optional"
|
||||
value={formData.note}
|
||||
onChange={e => setFormData(f => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
background: 'var(--danger-bg)',
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--danger)',
|
||||
marginTop: 8,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '...' : 'Speichern'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
setShowForm(false)
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
preset: null,
|
||||
note: '',
|
||||
})
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="section-gap">
|
||||
<div className="card-title" style={{ marginBottom: 8 }}>
|
||||
Verlauf ({restDays.length})
|
||||
</div>
|
||||
|
||||
{restDays.length === 0 && (
|
||||
<p className="muted">Noch keine Ruhetage erfasst.</p>
|
||||
)}
|
||||
|
||||
{restDays.map(day => {
|
||||
const isEditing = editing?.id === day.id
|
||||
const focus = day.rest_config?.focus || 'mental_rest'
|
||||
const preset = PRESETS.find(p => p.id === focus)
|
||||
const isToday = day.date === dayjs().format('YYYY-MM-DD')
|
||||
|
||||
return (
|
||||
<div key={day.id} className="card" style={{
|
||||
marginBottom: 8,
|
||||
border: isToday ? '2px solid var(--accent)' : undefined,
|
||||
background: isToday ? 'var(--accent)08' : undefined,
|
||||
}}>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
||||
{dayjs(day.date).format('DD. MMMM YYYY')}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Notiz</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editing.note}
|
||||
onChange={e => setEditing(d => ({ ...d, note: e.target.value }))}
|
||||
/>
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
<Check size={13} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
<X size={13} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{dayjs(day.date).format('DD. MMMM YYYY')}
|
||||
</div>
|
||||
{isToday && (
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
HEUTE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '5px 8px' }}
|
||||
onClick={() => startEdit(day)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '5px 8px' }}
|
||||
onClick={() => handleDelete(day.id)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Badge */}
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
background: `${preset?.color || '#888'}22`,
|
||||
border: `1px solid ${preset?.color || '#888'}`,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: preset?.color || '#888',
|
||||
marginBottom: day.note ? 8 : 0,
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>{preset?.icon || '📅'}</span>
|
||||
{FOCUS_LABELS[focus] || focus}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
{day.note && (
|
||||
<p style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text2)',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 6,
|
||||
}}>
|
||||
"{day.note}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Details (collapsible, optional for later) */}
|
||||
{day.rest_config && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
color: 'var(--text3)',
|
||||
}}>
|
||||
{day.rest_config.rest_from?.length > 0 && (
|
||||
<div>
|
||||
<strong>Pausiert:</strong> {day.rest_config.rest_from.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{day.rest_config.allows?.length > 0 && (
|
||||
<div>
|
||||
<strong>Erlaubt:</strong> {day.rest_config.allows.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{day.rest_config.intensity_max && (
|
||||
<div>
|
||||
<strong>Max Intensität:</strong> {day.rest_config.intensity_max}% HFmax
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,16 @@ function hdrs(extra={}) {
|
|||
|
||||
async function req(path, opts={}) {
|
||||
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
|
||||
if (!res.ok) { const err=await res.text(); throw new Error(err) }
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
// Try to parse JSON error with detail field
|
||||
try {
|
||||
const parsed = JSON.parse(err)
|
||||
throw new Error(parsed.detail || err)
|
||||
} catch {
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
||||
|
|
@ -230,4 +239,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})),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user