Production Release: RestDays Widget + Trainingstyp Fix #16
|
|
@ -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);
|
||||||
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 typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, require_admin
|
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_de,
|
||||||
data.description_en,
|
data.description_en,
|
||||||
data.sort_order,
|
data.sort_order,
|
||||||
abilities_json
|
Json(abilities_json)
|
||||||
))
|
))
|
||||||
|
|
||||||
new_id = cur.fetchone()['id']
|
new_id = cur.fetchone()['id']
|
||||||
|
|
@ -153,7 +154,7 @@ def update_training_type(
|
||||||
values.append(data.sort_order)
|
values.append(data.sort_order)
|
||||||
if data.abilities is not None:
|
if data.abilities is not None:
|
||||||
updates.append("abilities = %s")
|
updates.append("abilities = %s")
|
||||||
values.append(data.abilities)
|
values.append(Json(data.abilities))
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
raise HTTPException(400, "No fields to update")
|
raise HTTPException(400, "No fields to update")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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
|
from fastapi.responses import FileResponse
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@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)):
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Upload progress photo."""
|
"""Upload progress photo."""
|
||||||
pid = get_pid(x_profile_id)
|
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'
|
ext = Path(file.filename).suffix or '.jpg'
|
||||||
path = PHOTOS_DIR / f"{fid}{ext}"
|
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||||
async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
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
|
# Phase 2: Increment usage counter
|
||||||
increment_feature_usage(pid, 'photos')
|
increment_feature_usage(pid, 'photos')
|
||||||
|
|
||||||
return {"id":fid,"date":date}
|
return {"id":fid,"date":photo_date}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{fid}")
|
@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 AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import SleepPage from './pages/SleepPage'
|
import SleepPage from './pages/SleepPage'
|
||||||
|
import RestDaysPage from './pages/RestDaysPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -166,6 +167,7 @@ function AppShell() {
|
||||||
<Route path="/caliper" element={<CaliperScreen/>}/>
|
<Route path="/caliper" element={<CaliperScreen/>}/>
|
||||||
<Route path="/history" element={<History/>}/>
|
<Route path="/history" element={<History/>}/>
|
||||||
<Route path="/sleep" element={<SleepPage/>}/>
|
<Route path="/sleep" element={<SleepPage/>}/>
|
||||||
|
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||||
<Route path="/activity" element={<ActivityPage/>}/>
|
<Route path="/activity" element={<ActivityPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<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',
|
to: '/sleep',
|
||||||
color: '#7B68EE',
|
color: '#7B68EE',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '🛌',
|
||||||
|
label: 'Ruhetage',
|
||||||
|
sub: 'Kraft-, Cardio-, oder Entspannungs-Ruhetag erfassen',
|
||||||
|
to: '/rest-days',
|
||||||
|
color: '#9B59B6',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '📖',
|
icon: '📖',
|
||||||
label: 'Messanleitung',
|
label: 'Messanleitung',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import TrialBanner from '../components/TrialBanner'
|
||||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||||
import SleepWidget from '../components/SleepWidget'
|
import SleepWidget from '../components/SleepWidget'
|
||||||
|
import RestDaysWidget from '../components/RestDaysWidget'
|
||||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -477,6 +478,11 @@ export default function Dashboard() {
|
||||||
<SleepWidget/>
|
<SleepWidget/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rest Days Widget */}
|
||||||
|
<div style={{marginBottom:16}}>
|
||||||
|
<RestDaysWidget/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Training Type Distribution */}
|
{/* Training Type Distribution */}
|
||||||
{activities.length > 0 && (
|
{activities.length > 0 && (
|
||||||
<div className="card section-gap" style={{marginBottom:16}}>
|
<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={}) {
|
async function req(path, opts={}) {
|
||||||
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
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)
|
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