Compare commits

..

11 Commits

Author SHA1 Message Date
03f4b871a9 Merge pull request 'Production Release: RestDays Widget + Trainingstyp Fix' (#16) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
Merge pull request #16: Production Release
2026-03-23 09:24:17 +01:00
29770503bf fix: wrap abilities dict with Json() for JSONB insert (#13)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Problem: Creating new training types via Admin UI resulted in
'Internal Server Error' because abilities dict was passed directly
to PostgreSQL JSONB column without Json() wrapper.

Solution:
- Import Json from psycopg2.extras
- Wrap abilities_json with Json() in INSERT
- Wrap data.abilities with Json() in UPDATE

Same issue as rest_days JSONB fix (commit 7d627cf).

Closes #13
2026-03-23 09:13:50 +01:00
7a0b2097ae feat: dashboard rest days widget + today highlighting
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
- Add RestDaysWidget component showing today's rest days with icons & colors
- Integrate widget into Dashboard (above training distribution)
- Highlight current day in RestDaysPage (accent border + HEUTE badge)
- Fix: Improve error handling in api.js (parse JSON detail field)

Part of v9d Phase 2 (Vitals & Recovery)
2026-03-23 08:38:57 +01:00
f87b93ce2f feat: prevent duplicate rest day types per date (Migration 012)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Problem: User can create multiple rest days of same type per date
(e.g., 2x Mental Rest on 2026-03-23) - makes no sense.

Solution: UNIQUE constraint on (profile_id, date, focus)

## Migration 012:
- Add focus column (extracted from rest_config JSONB)
- Populate from existing data
- Add NOT NULL constraint
- Add CHECK constraint (valid focus values)
- Add UNIQUE constraint (profile_id, date, focus)
- Add index for performance

## Backend:
- Insert focus column alongside rest_config
- Handle UniqueViolation gracefully
- User-friendly error: "Du hast bereits einen Ruhetag 'Muskelregeneration' für 23.03."

## Benefits:
- DB-level enforcement (clean)
- Fast queries (no JSONB scan)
- Clear error messages
- Prevents: 2x muscle_recovery same day
- Allows: muscle_recovery + mental_rest same day ✓

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:36:49 +01:00
f2e2aff17f fix: remove ON CONFLICT clause after constraint removal
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Migration 011 removed UNIQUE constraint (profile_id, date) to allow
multiple rest days per date, but INSERT still used ON CONFLICT.

Error: psycopg2.errors.InvalidColumnReference: there is no unique or
exclusion constraint matching the ON CONFLICT specification

Solution: Remove ON CONFLICT clause, use plain INSERT.
Multiple entries per date now allowed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:05:06 +01:00
6916e5b808 feat: multi-dimensional rest days + development routes architecture (v9d → v9e)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
## Changes:

**Frontend:**
- Fix double icon in rest day list (removed icons from FOCUS_LABELS)
- Icon now shows once with proper styling

**Migration 011:**
- Remove UNIQUE constraint (profile_id, date) from rest_days
- Allow multiple rest day types per date
- Use case: Muscle recovery + Mental rest same day

**Architecture: Development Routes**
New document: `.claude/docs/functional/DEVELOPMENT_ROUTES.md`

6 Independent Development Routes:
- 💪 Kraft (Strength): Muscle, power, HIIT
- 🏃 Kondition (Conditioning): Cardio, endurance, VO2max
- 🧘 Mental: Stress, focus, competition readiness
- 🤸 Koordination (Coordination): Balance, agility, technique
- 🧘‍♂️ Mobilität (Mobility): Flexibility, ROM, fascia
- 🎯 Technik (Technique): Sport-specific skills

Each route has:
- Independent rest requirements
- Independent training plans
- Independent progress tracking
- Independent goals & habits

**Future (v9e):**
- Route-based weekly planning
- Multi-route conflict validation
- Auto-rest on poor recovery
- Route balance analysis (KI)

**Future (v9g):**
- Habits per route (route_habits table)
- Streak tracking per route
- Dashboard route-habits widget

**Backlog Updated:**
- v9d: Rest days  (in testing)
- v9e: Development Routes & Weekly Planning (new)
- v9g: Habits per Route (extended)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:51:09 +01:00
7d627cf128 fix: wrap rest_config dict with Json() for psycopg2 JSONB insert
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Error: psycopg2.ProgrammingError: can't adapt type 'dict'
Solution: Import psycopg2.extras.Json and wrap config_dict

Changes:
- Import Json from psycopg2.extras
- Wrap config_dict with Json() in INSERT
- Wrap config_dict with Json() in UPDATE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:38:39 +01:00
c265ab1245 feat: RestDaysPage UI with Quick Mode presets (v9d Phase 2a)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
Quick Mode with 4 presets:
- 💪 Kraft-Ruhetag (strength/hiit pause, cardio allowed, max 60%)
- 🏃 Cardio-Ruhetag (cardio pause, strength/mobility allowed, max 70%)
- 🧘 Entspannungstag (all pause, only meditation/walk, max 40%)
- 📉 Deload (all allowed, max 70% intensity)

Features:
- Preset selection with visual cards
- Date picker
- Optional note field
- List view with inline editing
- Delete with confirmation
- Toast notifications
- Detail view (shows rest_from, allows, intensity_max)

Integration:
- Route: /rest-days
- CaptureHub entry: 🛌 Ruhetage

Next Phase:
- Custom Mode (full control)
- Activity conflict warnings
- Weekly planning integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:33:32 +01:00
b63d15fd02 feat: flexible rest days system with JSONB config (v9d Phase 2a)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
PROBLEM: Simple full_rest/active_recovery model doesn't support
context-specific rest days (e.g., strength rest but cardio allowed).

SOLUTION: JSONB-based flexible rest day configuration.

## Changes:

**Migration 010:**
- Refactor rest_days.type → rest_config JSONB
- Schema: {focus, rest_from[], allows[], intensity_max}
- Validation function with check constraint
- GIN index for performant JSONB queries

**Backend (routers/rest_days.py):**
- CRUD: list, create (upsert by date), get, update, delete
- Stats: count per week, focus distribution
- Validation: check activity conflicts with rest day config

**Frontend (api.js):**
- 7 new methods: listRestDays, createRestDay, updateRestDay,
  deleteRestDay, getRestDaysStats, validateActivity

**Integration:**
- Router registered in main.py
- Ready for weekly planning validation rules

## Next Steps:
- Frontend UI (RestDaysPage with Quick/Custom mode)
- Activity conflict warnings
- Dashboard widget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:20:52 +01:00
0278a8e4a6 fix: photo upload date parameter parsing
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Problem: Photos were always getting NULL date instead of form date,
causing frontend to fallback to created timestamp (today).

Root cause: FastAPI requires Form() wrapper for form fields when
mixing with File() parameters. Without it, the date parameter was
treated as query parameter and always received empty string.

Solution:
- Import Form from fastapi
- Change date parameter from str="" to str=Form("")
- Return photo_date instead of date in response (consistency)

Now photos correctly use the date from the upload form and can be
backdated when uploading later.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:33:01 +01:00
ef27660fc8 fix: photo upload with empty date string
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Problem:
- Photo upload with empty date parameter (date='')
- PostgreSQL rejects empty string for DATE field
- Error: "invalid input syntax for type date: ''"
- Occurred when saving circumference entry with only photo

Fix:
- Convert empty string to NULL before INSERT
- Check: date if date and date.strip() else None
- NULL is valid for optional date field

Test case:
- Circumference entry with only photo → should work now
- Photo without date → stored with date=NULL ✓

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:25:27 +01:00
13 changed files with 1156 additions and 9 deletions

View File

@ -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("/")

View 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);

View 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)';

View 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.';

View File

@ -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")

View File

@ -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}")

View 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": ""}

View File

@ -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/>}/>

View 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>
)
}

View File

@ -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',

View File

@ -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}}>

View 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>
)
}

View File

@ -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})),
} }