mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 09:08:46 +01:00
Add supplements, kettlebell, calendar, push notifications, and PWA support
- Supplement tracking: CRUD endpoints, /today, /logs, Supplements page - Kettlebell workouts: session tracking, analytics endpoint, ActiveSession page - Calendar module: events CRUD, calendar components - Push notifications: VAPID keys, PushSubscription model, APScheduler reminders, service worker with push/notificationclick handlers, Profile notifications UI - PWA: vite-plugin-pwa, manifest, icons, service worker generation - Frontend: TypeScript types, API modules, ConfirmModal, toast notifications - Auth fixes: password hashing, nutrition endpoint auth - CLAUDE.md: project documentation and development guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
CLAUDE.md
Normal file
88
CLAUDE.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Start Everything
|
||||||
|
```bash
|
||||||
|
./develop.sh # starts PostgreSQL (Docker), backend (uvicorn), and frontend (vite) together
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
ruff check . # lint
|
||||||
|
ruff format . # format
|
||||||
|
pytest # run tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev # dev server on :5173
|
||||||
|
npm run build # production build
|
||||||
|
npm run lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
```bash
|
||||||
|
docker compose up -d # start PostgreSQL with pgvector on :5432
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Full-stack health/fitness tracker with AI-powered nutrition analysis and plan generation.
|
||||||
|
|
||||||
|
**Stack:** FastAPI (Python) + React 19 (TypeScript) + PostgreSQL/pgvector + DSPy + OpenAI GPT-4o
|
||||||
|
|
||||||
|
### Backend (`/backend`)
|
||||||
|
- `app/main.py` — FastAPI app, CORS config, router mounting
|
||||||
|
- `app/api/v1/endpoints/` — Route handlers: `login`, `users`, `nutrition`, `health`, `plans`
|
||||||
|
- `app/models/` — SQLModel ORM models (User, HealthMetric, HealthGoal, FoodLog, FoodItem, Plan)
|
||||||
|
- `app/ai/` — DSPy modules: `nutrition.py` (food analysis + image analysis), `plans.py` (personalized plan generation)
|
||||||
|
- `app/core/ai_config.py` — DSPy LM configuration (GPT-4o mini)
|
||||||
|
- `app/api/deps.py` — FastAPI dependencies for auth and DB session injection
|
||||||
|
|
||||||
|
### Frontend (`/src`)
|
||||||
|
- `App.tsx` — React Router routes; all routes except `/login` are wrapped in `ProtectedRoute`
|
||||||
|
- `api/client.ts` — Axios instance with base URL from `VITE_API_URL`; Bearer token auto-attached via interceptor from localStorage
|
||||||
|
- `context/AuthContext.tsx` — Auth state (token, user); token persisted to localStorage
|
||||||
|
- `pages/` — One page component per route (Dashboard, Nutrition, Health, Plans, Profile)
|
||||||
|
- `components/catalyst/` — Reusable UI component library (buttons, inputs, tables, dialogs, etc.)
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
OAuth2 password flow → JWT (HS256, 8-day expiry) → stored in localStorage → Axios interceptor injects header on every request.
|
||||||
|
|
||||||
|
### AI Modules (DSPy)
|
||||||
|
Both AI modules use chain-of-thought (`dspy.ChainOfThought`) via GPT-4o mini:
|
||||||
|
- **Nutrition:** Analyzes food text/images → macro breakdown accounting for hidden calories (oils, sauces)
|
||||||
|
- **Plans:** Generates personalized diet + exercise plans from user profile data
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
See `.env.example`. Required: `DATABASE_URL`, `OPENAI_API_KEY`, `SECRET_KEY`, `POSTGRES_*` vars. Frontend uses `VITE_API_URL` (defaults to `http://localhost:8000/api/v1`).
|
||||||
|
|
||||||
|
## Push Notifications
|
||||||
|
|
||||||
|
### Testing push notifications
|
||||||
|
**Always use the production build** — never test push notifications against `npm run dev`. The dev server's hot-reloading reinstalls the service worker on every reload, which invalidates push subscriptions (FCM returns 410 Gone).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
npx serve dist -p 5173 -s # -s = SPA mode (falls back to index.html)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the browser:
|
||||||
|
1. DevTools → Application → Service Workers → Unregister any old SW
|
||||||
|
2. Hard-refresh (`Cmd+Shift+R`) to install the fresh `sw.js`
|
||||||
|
3. Profile → Notifications → toggle ON to create a fresh subscription
|
||||||
|
4. Use "Send test" button or trigger via backend
|
||||||
|
|
||||||
|
### If push notifications stop working
|
||||||
|
- Check `chrome://gcm-internals/` — Connection State must be **CONNECTED** (not CONNECTING)
|
||||||
|
- If disconnected, fully quit and restart Chrome (`Cmd+Q`)
|
||||||
|
- Clear stale DB subscriptions: `docker compose exec db psql -U user -d healthyfit -c "DELETE FROM pushsubscription;"`
|
||||||
|
- Re-subscribe after restarting
|
||||||
57
backend/app/ai/kettlebell.py
Normal file
57
backend/app/ai/kettlebell.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import dspy
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseBlock(BaseModel):
|
||||||
|
order: int = Field(description="Position in workout sequence")
|
||||||
|
name: str = Field(description="Exercise name")
|
||||||
|
description: str = Field(description="Brief description of the movement")
|
||||||
|
sets: int = Field(description="Number of sets")
|
||||||
|
reps: int = Field(description="Number of reps per set (0 if timed)")
|
||||||
|
duration_seconds: int = Field(description="Duration in seconds per set (0 if rep-based)")
|
||||||
|
weight_kg: float = Field(description="Prescribed weight in kg")
|
||||||
|
rest_seconds: int = Field(description="Rest time between sets in seconds")
|
||||||
|
coaching_tip: str = Field(description="Key coaching cue for this exercise")
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellSessionOutput(BaseModel):
|
||||||
|
reasoning: str = Field(description="Step-by-step reasoning for session design choices")
|
||||||
|
title: str = Field(description="Session title")
|
||||||
|
focus: str = Field(description="Session focus e.g. strength, conditioning, mobility")
|
||||||
|
total_duration_min: int = Field(description="Estimated total workout duration in minutes")
|
||||||
|
difficulty: str = Field(description="Difficulty level: beginner, intermediate, advanced")
|
||||||
|
exercises: list[ExerciseBlock] = Field(description="Ordered list of exercises in the session")
|
||||||
|
notes: str = Field(description="Coaching notes and any special instructions for the session")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateKettlebellSession(dspy.Signature):
|
||||||
|
"""Generate a personalized kettlebell workout session based on user profile and preferences.
|
||||||
|
|
||||||
|
Think step-by-step: assess user fitness level, pick movements appropriate to the focus and
|
||||||
|
difficulty, assign weights respecting progressive overload principles from available weights,
|
||||||
|
sequence exercises for proper warm-up and fatigue management, and ensure total work time
|
||||||
|
(sets × reps/duration + rest periods) fits within the requested duration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_profile: str = dspy.InputField(desc="User details including age, weight, fitness level, and goals")
|
||||||
|
available_weights_kg: str = dspy.InputField(desc="Comma-separated list of available kettlebell weights in kg")
|
||||||
|
focus: str = dspy.InputField(desc="Session focus: strength, conditioning, mobility, fat loss, etc.")
|
||||||
|
duration_minutes: int = dspy.InputField(desc="Target session duration in minutes")
|
||||||
|
session: KettlebellSessionOutput = dspy.OutputField(desc="Complete structured kettlebell session")
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellModule(dspy.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.generate = dspy.ChainOfThought(GenerateKettlebellSession)
|
||||||
|
|
||||||
|
def forward(self, user_profile: str, available_weights_kg: str, focus: str, duration_minutes: int):
|
||||||
|
return self.generate(
|
||||||
|
user_profile=user_profile,
|
||||||
|
available_weights_kg=available_weights_kg,
|
||||||
|
focus=focus,
|
||||||
|
duration_minutes=duration_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
kettlebell_module = KettlebellModule()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints import health, login, nutrition, plans, users
|
from app.api.v1.endpoints import calendar, health, kettlebell, login, nutrition, plans, push, supplements, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(login.router, tags=["login"])
|
api_router.include_router(login.router, tags=["login"])
|
||||||
@@ -8,3 +8,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
|||||||
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(plans.router, prefix="/plans", tags=["plans"])
|
api_router.include_router(plans.router, prefix="/plans", tags=["plans"])
|
||||||
|
api_router.include_router(kettlebell.router, prefix="/kettlebell", tags=["kettlebell"])
|
||||||
|
api_router.include_router(supplements.router, prefix="/supplements", tags=["supplements"])
|
||||||
|
api_router.include_router(calendar.router, prefix="/calendar", tags=["calendar"])
|
||||||
|
api_router.include_router(push.router, prefix="/push", tags=["push"])
|
||||||
|
|||||||
462
backend/app/api/v1/endpoints/calendar.py
Normal file
462
backend/app/api/v1/endpoints/calendar.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
from calendar import monthrange
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.models.calendar import CalendarEvent, DailyNote
|
||||||
|
from app.models.kettlebell import KettlebellSession
|
||||||
|
from app.models.supplement import Supplement, SupplementLog
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DayMeta(BaseModel):
|
||||||
|
date: str
|
||||||
|
has_note: bool
|
||||||
|
event_count: int
|
||||||
|
supplement_compliance: Optional[float]
|
||||||
|
has_workout: bool
|
||||||
|
calorie_total: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class DailyNoteUpsert(BaseModel):
|
||||||
|
content: str = ""
|
||||||
|
mood: Optional[str] = None
|
||||||
|
energy_level: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DailyNoteOut(BaseModel):
|
||||||
|
id: Optional[int]
|
||||||
|
date: str
|
||||||
|
content: str
|
||||||
|
mood: Optional[str]
|
||||||
|
energy_level: Optional[int]
|
||||||
|
updated_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventCreate(BaseModel):
|
||||||
|
date: str # YYYY-MM-DD
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: str = "general"
|
||||||
|
color: Optional[str] = None
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
is_completed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
is_completed: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
date: str
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
event_type: str
|
||||||
|
color: Optional[str]
|
||||||
|
start_time: Optional[str]
|
||||||
|
is_completed: bool
|
||||||
|
created_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementWithStatus(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
dosage: float
|
||||||
|
unit: str
|
||||||
|
frequency: str
|
||||||
|
scheduled_times: List[str]
|
||||||
|
notes: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
taken_today: bool
|
||||||
|
streak: int
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellSessionSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
focus: str
|
||||||
|
total_duration_min: int
|
||||||
|
difficulty: str
|
||||||
|
status: str
|
||||||
|
completed_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DayDetail(BaseModel):
|
||||||
|
date: str
|
||||||
|
note: Optional[DailyNoteOut]
|
||||||
|
events: List[CalendarEventOut]
|
||||||
|
supplements: List[SupplementWithStatus]
|
||||||
|
kettlebell_sessions: List[KettlebellSessionSummary]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _day_window(d: date):
|
||||||
|
"""Return (start_dt, end_dt) for a full day."""
|
||||||
|
start = datetime(d.year, d.month, d.day, 0, 0, 0)
|
||||||
|
end = datetime(d.year, d.month, d.day, 23, 59, 59)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _note_out(note: DailyNote) -> DailyNoteOut:
|
||||||
|
return DailyNoteOut(
|
||||||
|
id=note.id,
|
||||||
|
date=note.date.isoformat(),
|
||||||
|
content=note.content,
|
||||||
|
mood=note.mood,
|
||||||
|
energy_level=note.energy_level,
|
||||||
|
updated_at=note.updated_at.isoformat() if note.updated_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_out(ev: CalendarEvent) -> CalendarEventOut:
|
||||||
|
return CalendarEventOut(
|
||||||
|
id=ev.id,
|
||||||
|
date=ev.date.isoformat(),
|
||||||
|
title=ev.title,
|
||||||
|
description=ev.description,
|
||||||
|
event_type=ev.event_type,
|
||||||
|
color=ev.color,
|
||||||
|
start_time=ev.start_time,
|
||||||
|
is_completed=ev.is_completed,
|
||||||
|
created_at=ev.created_at.isoformat() if ev.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/month", response_model=List[DayMeta])
|
||||||
|
def get_month_summary(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
year: int = Query(...),
|
||||||
|
month: int = Query(...),
|
||||||
|
) -> Any:
|
||||||
|
_, last_day = monthrange(year, month)
|
||||||
|
month_start = date(year, month, 1)
|
||||||
|
month_end = date(year, month, last_day)
|
||||||
|
month_start_dt = datetime(year, month, 1, 0, 0, 0)
|
||||||
|
month_end_dt = datetime(year, month, last_day, 23, 59, 59)
|
||||||
|
|
||||||
|
uid = current_user.id
|
||||||
|
|
||||||
|
# DailyNotes
|
||||||
|
notes = session.exec(
|
||||||
|
select(DailyNote)
|
||||||
|
.where(DailyNote.user_id == uid)
|
||||||
|
.where(DailyNote.date >= month_start)
|
||||||
|
.where(DailyNote.date <= month_end)
|
||||||
|
).all()
|
||||||
|
notes_by_date = {n.date: n for n in notes}
|
||||||
|
|
||||||
|
# CalendarEvents
|
||||||
|
events = session.exec(
|
||||||
|
select(CalendarEvent)
|
||||||
|
.where(CalendarEvent.user_id == uid)
|
||||||
|
.where(CalendarEvent.date >= month_start)
|
||||||
|
.where(CalendarEvent.date <= month_end)
|
||||||
|
).all()
|
||||||
|
event_counts: dict[date, int] = {}
|
||||||
|
for ev in events:
|
||||||
|
event_counts[ev.date] = event_counts.get(ev.date, 0) + 1
|
||||||
|
|
||||||
|
# SupplementLogs
|
||||||
|
active_supp_count = session.exec(
|
||||||
|
select(Supplement).where(Supplement.user_id == uid).where(Supplement.is_active)
|
||||||
|
).all()
|
||||||
|
total_supplements = len(active_supp_count)
|
||||||
|
supp_logs = session.exec(
|
||||||
|
select(SupplementLog)
|
||||||
|
.where(SupplementLog.user_id == uid)
|
||||||
|
.where(SupplementLog.taken_at >= month_start_dt)
|
||||||
|
.where(SupplementLog.taken_at <= month_end_dt)
|
||||||
|
).all()
|
||||||
|
supp_by_date: dict[date, set] = {}
|
||||||
|
for log in supp_logs:
|
||||||
|
d = log.taken_at.date()
|
||||||
|
supp_by_date.setdefault(d, set()).add(log.supplement_id)
|
||||||
|
|
||||||
|
# KettlebellSessions
|
||||||
|
kb_sessions = session.exec(
|
||||||
|
select(KettlebellSession)
|
||||||
|
.where(KettlebellSession.user_id == uid)
|
||||||
|
.where(KettlebellSession.status == "completed")
|
||||||
|
.where(KettlebellSession.completed_at >= month_start_dt)
|
||||||
|
.where(KettlebellSession.completed_at <= month_end_dt)
|
||||||
|
).all()
|
||||||
|
workout_dates: set[date] = set()
|
||||||
|
for kb in kb_sessions:
|
||||||
|
if kb.completed_at:
|
||||||
|
workout_dates.add(kb.completed_at.date())
|
||||||
|
|
||||||
|
# FoodLogs — import lazily to avoid circular imports
|
||||||
|
from app.models.food import FoodLog # noqa: PLC0415
|
||||||
|
|
||||||
|
food_logs = session.exec(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == uid)
|
||||||
|
.where(FoodLog.timestamp >= month_start_dt)
|
||||||
|
.where(FoodLog.timestamp <= month_end_dt)
|
||||||
|
).all()
|
||||||
|
calories_by_date: dict[date, float] = {}
|
||||||
|
for fl in food_logs:
|
||||||
|
d = fl.timestamp.date()
|
||||||
|
calories_by_date[d] = calories_by_date.get(d, 0.0) + (fl.calories or 0.0)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for day_num in range(1, last_day + 1):
|
||||||
|
d = date(year, month, day_num)
|
||||||
|
taken = len(supp_by_date.get(d, set()))
|
||||||
|
compliance = (taken / total_supplements) if total_supplements > 0 else None
|
||||||
|
result.append(
|
||||||
|
DayMeta(
|
||||||
|
date=d.isoformat(),
|
||||||
|
has_note=bool(notes_by_date.get(d) and notes_by_date[d].content),
|
||||||
|
event_count=event_counts.get(d, 0),
|
||||||
|
supplement_compliance=compliance,
|
||||||
|
has_workout=d in workout_dates,
|
||||||
|
calorie_total=calories_by_date.get(d) or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/day", response_model=DayDetail)
|
||||||
|
def get_day_detail(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
date_str: str = Query(..., alias="date"),
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
uid = current_user.id
|
||||||
|
start, end = _day_window(d)
|
||||||
|
|
||||||
|
note = session.exec(select(DailyNote).where(DailyNote.user_id == uid).where(DailyNote.date == d)).first()
|
||||||
|
|
||||||
|
events = session.exec(
|
||||||
|
select(CalendarEvent)
|
||||||
|
.where(CalendarEvent.user_id == uid)
|
||||||
|
.where(CalendarEvent.date == d)
|
||||||
|
.order_by(CalendarEvent.start_time)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Supplements with taken status for this date
|
||||||
|
active_supps = session.exec(
|
||||||
|
select(Supplement).where(Supplement.user_id == uid).where(Supplement.is_active).order_by(Supplement.name)
|
||||||
|
).all()
|
||||||
|
day_logs = session.exec(
|
||||||
|
select(SupplementLog)
|
||||||
|
.where(SupplementLog.user_id == uid)
|
||||||
|
.where(SupplementLog.taken_at >= start)
|
||||||
|
.where(SupplementLog.taken_at <= end)
|
||||||
|
).all()
|
||||||
|
taken_ids = {log.supplement_id for log in day_logs}
|
||||||
|
supplements = [
|
||||||
|
SupplementWithStatus(
|
||||||
|
id=s.id,
|
||||||
|
name=s.name,
|
||||||
|
dosage=s.dosage,
|
||||||
|
unit=s.unit,
|
||||||
|
frequency=s.frequency,
|
||||||
|
scheduled_times=s.scheduled_times or [],
|
||||||
|
notes=s.notes,
|
||||||
|
is_active=s.is_active,
|
||||||
|
created_at=s.created_at.isoformat(),
|
||||||
|
taken_today=s.id in taken_ids,
|
||||||
|
streak=0,
|
||||||
|
)
|
||||||
|
for s in active_supps
|
||||||
|
]
|
||||||
|
|
||||||
|
kb_sessions = session.exec(
|
||||||
|
select(KettlebellSession)
|
||||||
|
.where(KettlebellSession.user_id == uid)
|
||||||
|
.where(KettlebellSession.created_at >= start)
|
||||||
|
.where(KettlebellSession.created_at <= end)
|
||||||
|
).all()
|
||||||
|
kb_out = [
|
||||||
|
KettlebellSessionSummary(
|
||||||
|
id=kb.id,
|
||||||
|
title=kb.title,
|
||||||
|
focus=kb.focus,
|
||||||
|
total_duration_min=kb.total_duration_min,
|
||||||
|
difficulty=kb.difficulty,
|
||||||
|
status=kb.status,
|
||||||
|
completed_at=kb.completed_at.isoformat() if kb.completed_at else None,
|
||||||
|
)
|
||||||
|
for kb in kb_sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
return DayDetail(
|
||||||
|
date=d.isoformat(),
|
||||||
|
note=_note_out(note) if note else None,
|
||||||
|
events=[_event_out(ev) for ev in events],
|
||||||
|
supplements=supplements,
|
||||||
|
kettlebell_sessions=kb_out,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notes/{date_str}", response_model=DailyNoteOut)
|
||||||
|
def get_note(
|
||||||
|
date_str: str,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format.")
|
||||||
|
|
||||||
|
note = session.exec(
|
||||||
|
select(DailyNote).where(DailyNote.user_id == current_user.id).where(DailyNote.date == d)
|
||||||
|
).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notes/{date_str}", response_model=DailyNoteOut)
|
||||||
|
def upsert_note(
|
||||||
|
date_str: str,
|
||||||
|
data: DailyNoteUpsert,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format.")
|
||||||
|
|
||||||
|
note = session.exec(
|
||||||
|
select(DailyNote).where(DailyNote.user_id == current_user.id).where(DailyNote.date == d)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if note:
|
||||||
|
note.content = data.content
|
||||||
|
if data.mood is not None:
|
||||||
|
note.mood = data.mood
|
||||||
|
if data.energy_level is not None:
|
||||||
|
note.energy_level = data.energy_level
|
||||||
|
note.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
note = DailyNote(
|
||||||
|
user_id=current_user.id,
|
||||||
|
date=d,
|
||||||
|
content=data.content,
|
||||||
|
mood=data.mood,
|
||||||
|
energy_level=data.energy_level,
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(note)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(note)
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", response_model=List[CalendarEventOut])
|
||||||
|
def list_events(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
start: str = Query(...),
|
||||||
|
end: str = Query(...),
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
start_date = date.fromisoformat(start)
|
||||||
|
end_date = date.fromisoformat(end)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format.")
|
||||||
|
|
||||||
|
events = session.exec(
|
||||||
|
select(CalendarEvent)
|
||||||
|
.where(CalendarEvent.user_id == current_user.id)
|
||||||
|
.where(CalendarEvent.date >= start_date)
|
||||||
|
.where(CalendarEvent.date <= end_date)
|
||||||
|
.order_by(CalendarEvent.date, CalendarEvent.start_time)
|
||||||
|
).all()
|
||||||
|
return [_event_out(ev) for ev in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events", response_model=CalendarEventOut, status_code=201)
|
||||||
|
def create_event(
|
||||||
|
data: CalendarEventCreate,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(data.date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format.")
|
||||||
|
|
||||||
|
ev = CalendarEvent(
|
||||||
|
user_id=current_user.id,
|
||||||
|
date=d,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
event_type=data.event_type,
|
||||||
|
color=data.color,
|
||||||
|
start_time=data.start_time,
|
||||||
|
is_completed=data.is_completed,
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ev)
|
||||||
|
return _event_out(ev)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/events/{event_id}", response_model=CalendarEventOut)
|
||||||
|
def update_event(
|
||||||
|
event_id: int,
|
||||||
|
data: CalendarEventUpdate,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
ev = session.get(CalendarEvent, event_id)
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if ev.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(ev, key, value)
|
||||||
|
session.add(ev)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ev)
|
||||||
|
return _event_out(ev)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events/{event_id}", status_code=204)
|
||||||
|
def delete_event(
|
||||||
|
event_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> None:
|
||||||
|
ev = session.get(CalendarEvent, event_id)
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if ev.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
session.delete(ev)
|
||||||
|
session.commit()
|
||||||
404
backend/app/api/v1/endpoints/kettlebell.py
Normal file
404
backend/app/api/v1/endpoints/kettlebell.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.ai.kettlebell import kettlebell_module
|
||||||
|
from app.api import deps
|
||||||
|
from app.models.kettlebell import KettlebellSession, KettlebellSetLog
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellGenerateRequest(BaseModel):
|
||||||
|
focus: str
|
||||||
|
duration_minutes: int
|
||||||
|
available_weights: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
class LogSetRequest(BaseModel):
|
||||||
|
exercise_order: int
|
||||||
|
set_number: int
|
||||||
|
actual_reps: int
|
||||||
|
actual_weight_kg: float
|
||||||
|
actual_duration_seconds: int
|
||||||
|
perceived_effort: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteSessionRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseProgression(BaseModel):
|
||||||
|
date: str
|
||||||
|
max_weight: float
|
||||||
|
avg_rpe: float
|
||||||
|
total_volume: float
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalRecord(BaseModel):
|
||||||
|
exercise_name: str
|
||||||
|
max_weight: float
|
||||||
|
date: str
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyVolume(BaseModel):
|
||||||
|
week: str
|
||||||
|
sessions: int
|
||||||
|
total_volume: float
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsResponse(BaseModel):
|
||||||
|
weekly_sessions: List[WeeklyVolume]
|
||||||
|
exercise_progressions: Dict[str, List[ExerciseProgression]]
|
||||||
|
personal_records: List[PersonalRecord]
|
||||||
|
avg_rpe_trend: List[Dict]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics", response_model=AnalyticsResponse)
|
||||||
|
def get_analytics(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Get aggregated analytics across all completed sessions."""
|
||||||
|
completed_sessions = session.exec(
|
||||||
|
select(KettlebellSession)
|
||||||
|
.where(KettlebellSession.user_id == current_user.id)
|
||||||
|
.where(KettlebellSession.status == "completed")
|
||||||
|
.order_by(KettlebellSession.completed_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not completed_sessions:
|
||||||
|
return AnalyticsResponse(
|
||||||
|
weekly_sessions=[],
|
||||||
|
exercise_progressions={},
|
||||||
|
personal_records=[],
|
||||||
|
avg_rpe_trend=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
session_ids = [s.id for s in completed_sessions]
|
||||||
|
all_sets = session.exec(select(KettlebellSetLog).where(KettlebellSetLog.session_id.in_(session_ids))).all()
|
||||||
|
|
||||||
|
# Map session_id -> session
|
||||||
|
session_map = {s.id: s for s in completed_sessions}
|
||||||
|
# Map session_id -> exercise_order -> exercise_name
|
||||||
|
exercise_name_map: Dict[int, Dict[int, str]] = {}
|
||||||
|
for s in completed_sessions:
|
||||||
|
exercises = s.exercises.get("exercises", []) if s.exercises else []
|
||||||
|
exercise_name_map[s.id] = {ex["order"]: ex["name"] for ex in exercises if "order" in ex and "name" in ex}
|
||||||
|
|
||||||
|
# Weekly sessions + volume
|
||||||
|
weeks: Dict[str, Dict] = defaultdict(lambda: {"sessions": set(), "volume": 0.0})
|
||||||
|
for set_log in all_sets:
|
||||||
|
s = session_map.get(set_log.session_id)
|
||||||
|
if not s or not s.completed_at:
|
||||||
|
continue
|
||||||
|
week_start = s.completed_at - timedelta(days=s.completed_at.weekday())
|
||||||
|
week_key = week_start.strftime("%Y-%m-%d")
|
||||||
|
weeks[week_key]["sessions"].add(set_log.session_id)
|
||||||
|
volume = set_log.actual_reps * set_log.actual_weight_kg
|
||||||
|
weeks[week_key]["volume"] += volume
|
||||||
|
|
||||||
|
weekly_sessions = [
|
||||||
|
WeeklyVolume(week=k, sessions=len(v["sessions"]), total_volume=round(v["volume"], 1))
|
||||||
|
for k, v in sorted(weeks.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exercise progressions + PRs
|
||||||
|
exercise_data: Dict[str, List[Dict]] = defaultdict(list)
|
||||||
|
for set_log in all_sets:
|
||||||
|
s = session_map.get(set_log.session_id)
|
||||||
|
if not s or not s.completed_at:
|
||||||
|
continue
|
||||||
|
ex_name = exercise_name_map.get(set_log.session_id, {}).get(set_log.exercise_order)
|
||||||
|
if not ex_name:
|
||||||
|
continue
|
||||||
|
exercise_data[ex_name].append(
|
||||||
|
{
|
||||||
|
"date": s.completed_at.strftime("%Y-%m-%d"),
|
||||||
|
"weight": set_log.actual_weight_kg,
|
||||||
|
"rpe": set_log.perceived_effort,
|
||||||
|
"volume": set_log.actual_reps * set_log.actual_weight_kg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
exercise_progressions: Dict[str, List[ExerciseProgression]] = {}
|
||||||
|
personal_records: List[PersonalRecord] = []
|
||||||
|
|
||||||
|
for ex_name, sets in exercise_data.items():
|
||||||
|
by_date: Dict[str, Dict] = defaultdict(lambda: {"weights": [], "rpes": [], "volume": 0.0})
|
||||||
|
for s in sets:
|
||||||
|
by_date[s["date"]]["weights"].append(s["weight"])
|
||||||
|
by_date[s["date"]]["rpes"].append(s["rpe"])
|
||||||
|
by_date[s["date"]]["volume"] += s["volume"]
|
||||||
|
|
||||||
|
progressions = [
|
||||||
|
ExerciseProgression(
|
||||||
|
date=d,
|
||||||
|
max_weight=max(v["weights"]),
|
||||||
|
avg_rpe=round(sum(v["rpes"]) / len(v["rpes"]), 1),
|
||||||
|
total_volume=round(v["volume"], 1),
|
||||||
|
)
|
||||||
|
for d, v in sorted(by_date.items())
|
||||||
|
]
|
||||||
|
exercise_progressions[ex_name] = progressions
|
||||||
|
|
||||||
|
all_weights = [s["weight"] for s in sets]
|
||||||
|
max_w = max(all_weights)
|
||||||
|
pr_date = next(s["date"] for s in sorted(sets, key=lambda x: x["date"]) if s["weight"] == max_w)
|
||||||
|
personal_records.append(PersonalRecord(exercise_name=ex_name, max_weight=max_w, date=pr_date))
|
||||||
|
|
||||||
|
personal_records.sort(key=lambda x: x.max_weight, reverse=True)
|
||||||
|
|
||||||
|
# Avg RPE trend by session
|
||||||
|
avg_rpe_trend = []
|
||||||
|
sets_by_session: Dict[int, List] = defaultdict(list)
|
||||||
|
for set_log in all_sets:
|
||||||
|
sets_by_session[set_log.session_id].append(set_log.perceived_effort)
|
||||||
|
|
||||||
|
for s in completed_sessions:
|
||||||
|
rpes = sets_by_session.get(s.id, [])
|
||||||
|
if rpes and s.completed_at:
|
||||||
|
avg_rpe_trend.append(
|
||||||
|
{
|
||||||
|
"date": s.completed_at.strftime("%Y-%m-%d"),
|
||||||
|
"avg_rpe": round(sum(rpes) / len(rpes), 1),
|
||||||
|
"session_title": s.title,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnalyticsResponse(
|
||||||
|
weekly_sessions=weekly_sessions,
|
||||||
|
exercise_progressions=exercise_progressions,
|
||||||
|
personal_records=personal_records,
|
||||||
|
avg_rpe_trend=avg_rpe_trend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=KettlebellSession)
|
||||||
|
def generate_session(
|
||||||
|
*,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
request: KettlebellGenerateRequest,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Generate a new kettlebell session using AI."""
|
||||||
|
try:
|
||||||
|
user_profile = (
|
||||||
|
f"Age: {current_user.age or 'unknown'}, "
|
||||||
|
f"Gender: {current_user.gender or 'unknown'}, "
|
||||||
|
f"Weight: {current_user.weight or 'unknown'} kg, "
|
||||||
|
f"Height: {current_user.height or 'unknown'} cm"
|
||||||
|
)
|
||||||
|
available_weights_kg = ", ".join(str(w) for w in sorted(request.available_weights))
|
||||||
|
|
||||||
|
generated = kettlebell_module(
|
||||||
|
user_profile=user_profile,
|
||||||
|
available_weights_kg=available_weights_kg,
|
||||||
|
focus=request.focus,
|
||||||
|
duration_minutes=request.duration_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
s = generated.session
|
||||||
|
kb_session = KettlebellSession(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=s.title,
|
||||||
|
focus=s.focus,
|
||||||
|
exercises={"exercises": [ex.model_dump() for ex in s.exercises]},
|
||||||
|
total_duration_min=s.total_duration_min,
|
||||||
|
difficulty=s.difficulty,
|
||||||
|
notes=s.notes,
|
||||||
|
status="generated",
|
||||||
|
)
|
||||||
|
session.add(kb_session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(kb_session)
|
||||||
|
return kb_session
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[KettlebellSession])
|
||||||
|
def list_sessions(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Get all kettlebell sessions for the current user."""
|
||||||
|
statement = (
|
||||||
|
select(KettlebellSession)
|
||||||
|
.where(KettlebellSession.user_id == current_user.id)
|
||||||
|
.order_by(KettlebellSession.created_at.desc())
|
||||||
|
)
|
||||||
|
return session.exec(statement).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_model=KettlebellSession)
|
||||||
|
def get_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Get a single kettlebell session."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
return kb_session
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{session_id}/start", response_model=KettlebellSession)
|
||||||
|
def start_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Mark a session as in progress."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
kb_session.status = "in_progress"
|
||||||
|
kb_session.started_at = datetime.utcnow()
|
||||||
|
session.add(kb_session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(kb_session)
|
||||||
|
return kb_session
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/sets", response_model=KettlebellSetLog)
|
||||||
|
def log_set(
|
||||||
|
session_id: int,
|
||||||
|
request: LogSetRequest,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Log a completed set."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
set_log = KettlebellSetLog(
|
||||||
|
session_id=session_id,
|
||||||
|
exercise_order=request.exercise_order,
|
||||||
|
set_number=request.set_number,
|
||||||
|
actual_reps=request.actual_reps,
|
||||||
|
actual_weight_kg=request.actual_weight_kg,
|
||||||
|
actual_duration_seconds=request.actual_duration_seconds,
|
||||||
|
perceived_effort=request.perceived_effort,
|
||||||
|
)
|
||||||
|
session.add(set_log)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(set_log)
|
||||||
|
return set_log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/sets", response_model=List[KettlebellSetLog])
|
||||||
|
def get_sets(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Get all logged sets for a session."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
statement = select(KettlebellSetLog).where(KettlebellSetLog.session_id == session_id)
|
||||||
|
return session.exec(statement).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/retry", response_model=KettlebellSession)
|
||||||
|
def retry_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Clone an abandoned session as a fresh generated session."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
new_session = KettlebellSession(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=kb_session.title,
|
||||||
|
focus=kb_session.focus,
|
||||||
|
exercises=kb_session.exercises,
|
||||||
|
total_duration_min=kb_session.total_duration_min,
|
||||||
|
difficulty=kb_session.difficulty,
|
||||||
|
notes="",
|
||||||
|
status="generated",
|
||||||
|
)
|
||||||
|
session.add(new_session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_session)
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{session_id}/abandon", response_model=KettlebellSession)
|
||||||
|
def abandon_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Mark a session as abandoned."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
kb_session.status = "abandoned"
|
||||||
|
session.add(kb_session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(kb_session)
|
||||||
|
return kb_session
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{session_id}", status_code=204)
|
||||||
|
def delete_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a session and all its set logs."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
statement = select(KettlebellSetLog).where(KettlebellSetLog.session_id == session_id)
|
||||||
|
for log in session.exec(statement).all():
|
||||||
|
session.delete(log)
|
||||||
|
session.flush()
|
||||||
|
session.delete(kb_session)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{session_id}/complete", response_model=KettlebellSession)
|
||||||
|
def complete_session(
|
||||||
|
session_id: int,
|
||||||
|
request: CompleteSessionRequest,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
"""Mark a session as completed."""
|
||||||
|
kb_session = session.get(KettlebellSession, session_id)
|
||||||
|
if not kb_session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
if kb_session.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
kb_session.status = "completed"
|
||||||
|
kb_session.completed_at = datetime.utcnow()
|
||||||
|
if request.notes is not None:
|
||||||
|
kb_session.notes = request.notes
|
||||||
|
session.add(kb_session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(kb_session)
|
||||||
|
return kb_session
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
from datetime import date as date_type
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module
|
from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
@@ -16,9 +18,18 @@ class AnalyzeRequest(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLogCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
calories: float
|
||||||
|
protein: float
|
||||||
|
carbs: float
|
||||||
|
fats: float
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze", response_model=NutritionalInfo)
|
@router.post("/analyze", response_model=NutritionalInfo)
|
||||||
def analyze_food(
|
def analyze_food(
|
||||||
request: AnalyzeRequest,
|
request: AnalyzeRequest,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Analyze food description and return nutritional info using DSPy.
|
Analyze food description and return nutritional info using DSPy.
|
||||||
@@ -32,6 +43,7 @@ def analyze_food(
|
|||||||
|
|
||||||
@router.post("/analyze/image", response_model=NutritionalInfo)
|
@router.post("/analyze/image", response_model=NutritionalInfo)
|
||||||
async def analyze_food_image(
|
async def analyze_food_image(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -51,7 +63,7 @@ async def analyze_food_image(
|
|||||||
def log_food(
|
def log_food(
|
||||||
*,
|
*,
|
||||||
session: Session = Depends(deps.get_session),
|
session: Session = Depends(deps.get_session),
|
||||||
nutrition_info: NutritionalInfo,
|
nutrition_info: FoodLogCreate,
|
||||||
current_user: deps.CurrentUser,
|
current_user: deps.CurrentUser,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -81,8 +93,6 @@ def read_logs(
|
|||||||
"""
|
"""
|
||||||
Get food logs for current user.
|
Get food logs for current user.
|
||||||
"""
|
"""
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
statement = (
|
statement = (
|
||||||
select(FoodLog)
|
select(FoodLog)
|
||||||
.where(FoodLog.user_id == current_user.id)
|
.where(FoodLog.user_id == current_user.id)
|
||||||
@@ -91,3 +101,55 @@ def read_logs(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
return session.exec(statement).all()
|
return session.exec(statement).all()
|
||||||
|
|
||||||
|
|
||||||
|
class NutritionSummary(BaseModel):
|
||||||
|
date: str
|
||||||
|
total_calories: float
|
||||||
|
total_protein: float
|
||||||
|
total_carbs: float
|
||||||
|
total_fats: float
|
||||||
|
log_count: int
|
||||||
|
target_calories: float | None
|
||||||
|
target_protein: float | None
|
||||||
|
target_carbs: float | None
|
||||||
|
target_fat: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=NutritionSummary)
|
||||||
|
def get_nutrition_summary(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
date: str = Query(default=None, description="Date in YYYY-MM-DD format, defaults to today"),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get aggregated macro totals for a given day.
|
||||||
|
"""
|
||||||
|
if date:
|
||||||
|
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
|
else:
|
||||||
|
target_date = date_type.today()
|
||||||
|
|
||||||
|
start = datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0)
|
||||||
|
end = datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59)
|
||||||
|
|
||||||
|
statement = (
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == current_user.id)
|
||||||
|
.where(FoodLog.timestamp >= start)
|
||||||
|
.where(FoodLog.timestamp <= end)
|
||||||
|
)
|
||||||
|
logs = session.exec(statement).all()
|
||||||
|
|
||||||
|
return NutritionSummary(
|
||||||
|
date=target_date.isoformat(),
|
||||||
|
total_calories=sum(log.calories for log in logs),
|
||||||
|
total_protein=sum(log.protein for log in logs),
|
||||||
|
total_carbs=sum(log.carbs for log in logs),
|
||||||
|
total_fats=sum(log.fats for log in logs),
|
||||||
|
log_count=len(logs),
|
||||||
|
target_calories=getattr(current_user, "target_calories", None),
|
||||||
|
target_protein=getattr(current_user, "target_protein", None),
|
||||||
|
target_carbs=getattr(current_user, "target_carbs", None),
|
||||||
|
target_fat=getattr(current_user, "target_fat", None),
|
||||||
|
)
|
||||||
|
|||||||
126
backend/app/api/v1/endpoints/push.py
Normal file
126
backend/app/api/v1/endpoints/push.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.push_subscription import PushSubscription
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionKeys(BaseModel):
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: SubscriptionKeys
|
||||||
|
reminder_hour: int = 9
|
||||||
|
reminder_minute: int = 0
|
||||||
|
timezone: str = "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
class UnsubscribeRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
|
||||||
|
|
||||||
|
def send_push(
|
||||||
|
subscriptions: list[PushSubscription],
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
url: str = "/supplements",
|
||||||
|
session: Session | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||||
|
for sub in subscriptions:
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": sub.endpoint,
|
||||||
|
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
|
||||||
|
},
|
||||||
|
data=payload,
|
||||||
|
vapid_private_key=settings.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims={"sub": settings.VAPID_MAILTO},
|
||||||
|
)
|
||||||
|
except WebPushException as ex:
|
||||||
|
if ex.response and ex.response.status_code in (404, 410):
|
||||||
|
sub.is_active = False
|
||||||
|
if session:
|
||||||
|
session.add(sub)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-public-key")
|
||||||
|
def get_vapid_public_key() -> dict:
|
||||||
|
return {"public_key": settings.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", status_code=201)
|
||||||
|
def subscribe(
|
||||||
|
data: SubscribeRequest,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> dict:
|
||||||
|
existing = session.exec(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)).first()
|
||||||
|
if existing:
|
||||||
|
existing.user_id = current_user.id
|
||||||
|
existing.p256dh = data.keys.p256dh
|
||||||
|
existing.auth = data.keys.auth
|
||||||
|
existing.reminder_hour = data.reminder_hour
|
||||||
|
existing.reminder_minute = data.reminder_minute
|
||||||
|
existing.timezone = data.timezone
|
||||||
|
existing.is_active = True
|
||||||
|
session.add(existing)
|
||||||
|
else:
|
||||||
|
sub = PushSubscription(
|
||||||
|
user_id=current_user.id,
|
||||||
|
endpoint=data.endpoint,
|
||||||
|
p256dh=data.keys.p256dh,
|
||||||
|
auth=data.keys.auth,
|
||||||
|
reminder_hour=data.reminder_hour,
|
||||||
|
reminder_minute=data.reminder_minute,
|
||||||
|
timezone=data.timezone,
|
||||||
|
)
|
||||||
|
session.add(sub)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "subscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/unsubscribe")
|
||||||
|
def unsubscribe(
|
||||||
|
data: UnsubscribeRequest,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> dict:
|
||||||
|
sub = session.exec(
|
||||||
|
select(PushSubscription)
|
||||||
|
.where(PushSubscription.endpoint == data.endpoint)
|
||||||
|
.where(PushSubscription.user_id == current_user.id)
|
||||||
|
).first()
|
||||||
|
if sub:
|
||||||
|
sub.is_active = False
|
||||||
|
session.add(sub)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "unsubscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
def send_test_notification(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> dict:
|
||||||
|
subs = session.exec(
|
||||||
|
select(PushSubscription)
|
||||||
|
.where(PushSubscription.user_id == current_user.id)
|
||||||
|
.where(PushSubscription.is_active == True) # noqa: E712
|
||||||
|
).all()
|
||||||
|
if not subs:
|
||||||
|
raise HTTPException(status_code=404, detail="No active subscriptions")
|
||||||
|
send_push(list(subs), title="Test Notification", body="Push notifications are working!", session=session)
|
||||||
|
session.commit()
|
||||||
|
return {"sent": len(subs)}
|
||||||
238
backend/app/api/v1/endpoints/supplements.py
Normal file
238
backend/app/api/v1/endpoints/supplements.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.models.supplement import Supplement, SupplementLog
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
dosage: float
|
||||||
|
unit: str
|
||||||
|
frequency: str = "daily"
|
||||||
|
scheduled_times: List[str] = []
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
dosage: Optional[float] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
frequency: Optional[str] = None
|
||||||
|
scheduled_times: Optional[List[str]] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementLogCreate(BaseModel):
|
||||||
|
dose_taken: Optional[float] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
taken_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementWithStatus(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
dosage: float
|
||||||
|
unit: str
|
||||||
|
frequency: str
|
||||||
|
scheduled_times: List[str]
|
||||||
|
notes: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
taken_today: bool
|
||||||
|
streak: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Supplement])
|
||||||
|
def list_supplements(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
statement = (
|
||||||
|
select(Supplement)
|
||||||
|
.where(Supplement.user_id == current_user.id)
|
||||||
|
.where(Supplement.is_active)
|
||||||
|
.order_by(Supplement.name)
|
||||||
|
)
|
||||||
|
return session.exec(statement).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=Supplement)
|
||||||
|
def create_supplement(
|
||||||
|
*,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
data: SupplementCreate,
|
||||||
|
) -> Any:
|
||||||
|
supplement = Supplement(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=data.name,
|
||||||
|
dosage=data.dosage,
|
||||||
|
unit=data.unit,
|
||||||
|
frequency=data.frequency,
|
||||||
|
scheduled_times=data.scheduled_times,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
session.add(supplement)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(supplement)
|
||||||
|
return supplement
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{supplement_id}", response_model=Supplement)
|
||||||
|
def update_supplement(
|
||||||
|
supplement_id: int,
|
||||||
|
data: SupplementUpdate,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
supplement = session.get(Supplement, supplement_id)
|
||||||
|
if not supplement:
|
||||||
|
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||||
|
if supplement.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(supplement, key, value)
|
||||||
|
session.add(supplement)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(supplement)
|
||||||
|
return supplement
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{supplement_id}", status_code=204)
|
||||||
|
def delete_supplement(
|
||||||
|
supplement_id: int,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> None:
|
||||||
|
supplement = session.get(Supplement, supplement_id)
|
||||||
|
if not supplement:
|
||||||
|
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||||
|
if supplement.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
# Soft delete
|
||||||
|
supplement.is_active = False
|
||||||
|
session.add(supplement)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{supplement_id}/log", response_model=SupplementLog)
|
||||||
|
def log_supplement(
|
||||||
|
supplement_id: int,
|
||||||
|
data: SupplementLogCreate,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
supplement = session.get(Supplement, supplement_id)
|
||||||
|
if not supplement:
|
||||||
|
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||||
|
if supplement.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
log = SupplementLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
supplement_id=supplement_id,
|
||||||
|
taken_at=data.taken_at or datetime.utcnow(),
|
||||||
|
dose_taken=data.dose_taken,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
session.add(log)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=List[SupplementLog])
|
||||||
|
def get_supplement_logs(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
supplement_id: Optional[int] = Query(default=None),
|
||||||
|
start_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
end_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
) -> Any:
|
||||||
|
statement = select(SupplementLog).where(SupplementLog.user_id == current_user.id)
|
||||||
|
if supplement_id:
|
||||||
|
statement = statement.where(SupplementLog.supplement_id == supplement_id)
|
||||||
|
if start_date:
|
||||||
|
dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
statement = statement.where(SupplementLog.taken_at >= dt)
|
||||||
|
if end_date:
|
||||||
|
dt = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||||
|
statement = statement.where(SupplementLog.taken_at <= dt)
|
||||||
|
statement = statement.order_by(SupplementLog.taken_at.desc())
|
||||||
|
return session.exec(statement).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today", response_model=List[SupplementWithStatus])
|
||||||
|
def get_today_supplements(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
) -> Any:
|
||||||
|
today = date.today()
|
||||||
|
start = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||||
|
end = datetime(today.year, today.month, today.day, 23, 59, 59)
|
||||||
|
|
||||||
|
supplements = session.exec(
|
||||||
|
select(Supplement)
|
||||||
|
.where(Supplement.user_id == current_user.id)
|
||||||
|
.where(Supplement.is_active)
|
||||||
|
.order_by(Supplement.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
today_logs = session.exec(
|
||||||
|
select(SupplementLog)
|
||||||
|
.where(SupplementLog.user_id == current_user.id)
|
||||||
|
.where(SupplementLog.taken_at >= start)
|
||||||
|
.where(SupplementLog.taken_at <= end)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
taken_ids = {log.supplement_id for log in today_logs}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for s in supplements:
|
||||||
|
# Calculate streak: consecutive days taken
|
||||||
|
streak = 0
|
||||||
|
check_date = today
|
||||||
|
while True:
|
||||||
|
d_start = datetime(check_date.year, check_date.month, check_date.day, 0, 0, 0)
|
||||||
|
d_end = datetime(check_date.year, check_date.month, check_date.day, 23, 59, 59)
|
||||||
|
taken = session.exec(
|
||||||
|
select(SupplementLog)
|
||||||
|
.where(SupplementLog.supplement_id == s.id)
|
||||||
|
.where(SupplementLog.taken_at >= d_start)
|
||||||
|
.where(SupplementLog.taken_at <= d_end)
|
||||||
|
).first()
|
||||||
|
if taken:
|
||||||
|
streak += 1
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
check_date = check_date - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if streak > 365:
|
||||||
|
break
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
SupplementWithStatus(
|
||||||
|
id=s.id,
|
||||||
|
name=s.name,
|
||||||
|
dosage=s.dosage,
|
||||||
|
unit=s.unit,
|
||||||
|
frequency=s.frequency,
|
||||||
|
scheduled_times=s.scheduled_times or [],
|
||||||
|
notes=s.notes,
|
||||||
|
is_active=s.is_active,
|
||||||
|
created_at=s.created_at,
|
||||||
|
taken_today=s.id in taken_ids,
|
||||||
|
streak=streak,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -59,6 +59,8 @@ def update_user_me(
|
|||||||
Update own user.
|
Update own user.
|
||||||
"""
|
"""
|
||||||
user_data = user_in.model_dump(exclude_unset=True)
|
user_data = user_in.model_dump(exclude_unset=True)
|
||||||
|
if "password" in user_data:
|
||||||
|
user_data["password_hash"] = security.get_password_hash(user_data.pop("password"))
|
||||||
current_user.sqlmodel_update(user_data)
|
current_user.sqlmodel_update(user_data)
|
||||||
session.add(current_user)
|
session.add(current_user)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
27
backend/app/models/calendar.py
Normal file
27
backend/app/models/calendar.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import datetime as dt
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class DailyNote(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
date: dt.date = Field(index=True)
|
||||||
|
content: str = Field(default="", max_length=10000)
|
||||||
|
mood: Optional[str] = None # "great"/"good"/"okay"/"bad"/"awful"
|
||||||
|
energy_level: Optional[int] = None # 1–10
|
||||||
|
updated_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEvent(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
date: dt.date = Field(index=True)
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: str = "general" # "workout" | "supplement" | "general"
|
||||||
|
color: Optional[str] = None
|
||||||
|
start_time: Optional[str] = None # "HH:MM"
|
||||||
|
is_completed: bool = False
|
||||||
|
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)
|
||||||
44
backend/app/models/kettlebell.py
Normal file
44
backend/app/models/kettlebell.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import JSON, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseBlock(BaseModel):
|
||||||
|
order: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
sets: int
|
||||||
|
reps: int
|
||||||
|
duration_seconds: int # reps=0 → timed, duration_seconds=0 → rep-based
|
||||||
|
weight_kg: float
|
||||||
|
rest_seconds: int
|
||||||
|
coaching_tip: str
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellSession(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
title: str
|
||||||
|
focus: str # e.g. "strength", "conditioning"
|
||||||
|
exercises: dict = Field(default={}, sa_type=JSON) # full AI-prescribed exercise list
|
||||||
|
total_duration_min: int
|
||||||
|
difficulty: str
|
||||||
|
notes: str = Field(default="")
|
||||||
|
status: str = Field(default="generated") # "generated" | "in_progress" | "completed" | "abandoned"
|
||||||
|
started_at: Optional[datetime] = Field(default=None)
|
||||||
|
completed_at: Optional[datetime] = Field(default=None)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class KettlebellSetLog(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
session_id: int = Field(foreign_key="kettlebellsession.id", index=True)
|
||||||
|
exercise_order: int
|
||||||
|
set_number: int
|
||||||
|
actual_reps: int
|
||||||
|
actual_weight_kg: float
|
||||||
|
actual_duration_seconds: int
|
||||||
|
perceived_effort: int # 1–10 RPE
|
||||||
|
completed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
17
backend/app/models/push_subscription.py
Normal file
17
backend/app/models/push_subscription.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
endpoint: str = Field(unique=True, index=True)
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
reminder_hour: int = Field(default=9)
|
||||||
|
reminder_minute: int = Field(default=0)
|
||||||
|
timezone: str = Field(default="UTC")
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
27
backend/app/models/supplement.py
Normal file
27
backend/app/models/supplement.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlmodel import JSON, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Supplement(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
dosage: float
|
||||||
|
unit: str # mg / mcg / IU / g
|
||||||
|
frequency: str = Field(default="daily") # daily / weekly / as_needed
|
||||||
|
scheduled_times: List[str] = Field(default=[], sa_column=Column(JSON)) # ["08:00", "20:00"]
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SupplementLog(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
supplement_id: int = Field(foreign_key="supplement.id", index=True)
|
||||||
|
taken_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
dose_taken: Optional[float] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
@@ -19,4 +19,10 @@ class User(SQLModel, table=True):
|
|||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
unit_preference: str = Field(default="metric") # "metric" or "imperial"
|
unit_preference: str = Field(default="metric") # "metric" or "imperial"
|
||||||
|
|
||||||
|
# Nutrition Targets
|
||||||
|
target_calories: Optional[float] = None
|
||||||
|
target_protein: Optional[float] = None
|
||||||
|
target_carbs: Optional[float] = None
|
||||||
|
target_fat: Optional[float] = None
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
47
backend/app/scheduler.py
Normal file
47
backend/app/scheduler.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
from app.models.push_subscription import PushSubscription
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def send_supplement_reminders() -> None:
|
||||||
|
from app.api.v1.endpoints.push import send_push
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
subs = session.exec(
|
||||||
|
select(PushSubscription).where(PushSubscription.is_active == True) # noqa: E712
|
||||||
|
).all()
|
||||||
|
due = []
|
||||||
|
for sub in subs:
|
||||||
|
try:
|
||||||
|
tz = pytz.timezone(sub.timezone)
|
||||||
|
local_now = now_utc.astimezone(tz)
|
||||||
|
if local_now.hour == sub.reminder_hour and local_now.minute == sub.reminder_minute:
|
||||||
|
due.append(sub)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if due:
|
||||||
|
send_push(due, title="Supplement Reminder", body="Time to log your supplements for today!", session=session)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
scheduler.add_job(
|
||||||
|
send_supplement_reminders,
|
||||||
|
CronTrigger(minute="*"),
|
||||||
|
id="supplement_reminders",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
@@ -21,6 +21,10 @@ class UserRead(UserBase):
|
|||||||
height: Optional[float] = None
|
height: Optional[float] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
unit_preference: str = "metric"
|
unit_preference: str = "metric"
|
||||||
|
target_calories: Optional[float] = None
|
||||||
|
target_protein: Optional[float] = None
|
||||||
|
target_carbs: Optional[float] = None
|
||||||
|
target_fat: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
@@ -34,3 +38,7 @@ class UserUpdate(SQLModel):
|
|||||||
height: Optional[float] = None
|
height: Optional[float] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
unit_preference: Optional[str] = None
|
unit_preference: Optional[str] = None
|
||||||
|
target_calories: Optional[float] = None
|
||||||
|
target_protein: Optional[float] = None
|
||||||
|
target_carbs: Optional[float] = None
|
||||||
|
target_fat: Optional[float] = None
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ pytest
|
|||||||
httpx
|
httpx
|
||||||
python-dotenv
|
python-dotenv
|
||||||
ruff
|
ruff
|
||||||
|
pywebpush
|
||||||
|
apscheduler
|
||||||
|
pytz
|
||||||
|
|
||||||
|
|||||||
1
frontend/dev-dist/registerSW.js
Normal file
1
frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="theme-color" content="#556B2F" />
|
||||||
|
<meta name="description" content="AI-powered health & fitness tracker" />
|
||||||
|
<title>HealthyFit</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4236
frontend/package-lock.json
generated
4236
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,12 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.27.0",
|
"motion": "^12.27.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0"
|
"recharts": "^3.6.0",
|
||||||
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
39
frontend/public/push-sw.js
Normal file
39
frontend/public/push-sw.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('[SW] push received', event.data?.text());
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let title = 'HealthyFit';
|
||||||
|
let body = 'You have a new notification';
|
||||||
|
let url = '/';
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
title = data.title;
|
||||||
|
body = data.body;
|
||||||
|
url = data.url ?? '/';
|
||||||
|
} catch {
|
||||||
|
body = event.data.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { url },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url ?? '/';
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clients) => {
|
||||||
|
const existing = clients.find((c) => c.url.includes(url));
|
||||||
|
if (existing) return existing.focus();
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Nutrition from './pages/Nutrition';
|
import Nutrition from './pages/Nutrition';
|
||||||
import Health from './pages/Health';
|
import Health from './pages/Health';
|
||||||
import Plans from './pages/Plans';
|
import Plans from './pages/Plans';
|
||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
|
import Kettlebell from './pages/Kettlebell';
|
||||||
|
import ActiveSession from './pages/ActiveSession';
|
||||||
|
import Supplements from './pages/Supplements';
|
||||||
|
import KettlebellAnalytics from './pages/KettlebellAnalytics';
|
||||||
|
import CalendarPage from './pages/Calendar';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import AppLayout from './components/Layout/AppLayout';
|
import AppLayout from './components/Layout/AppLayout';
|
||||||
|
|
||||||
@@ -15,6 +21,7 @@ function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
<div className="min-h-screen font-sans bg-base text-content transition-colors duration-200">
|
<div className="min-h-screen font-sans bg-base text-content transition-colors duration-200">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -55,6 +62,42 @@ function App() {
|
|||||||
</AppLayout>
|
</AppLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/kettlebell" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Kettlebell />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/supplements" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Supplements />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/kettlebell/analytics" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<KettlebellAnalytics />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/calendar" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<CalendarPage />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Full-screen active session — no AppLayout/sidebar */}
|
||||||
|
<Route path="/kettlebell/session/:id" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ActiveSession />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
26
frontend/src/api/calendar.ts
Normal file
26
frontend/src/api/calendar.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { DayMeta, DailyNote, CalendarEvent, DayDetail } from '../types/calendar';
|
||||||
|
|
||||||
|
export const getMonthSummary = (year: number, month: number) =>
|
||||||
|
client.get<DayMeta[]>('/calendar/month', { params: { year, month } }).then(r => r.data);
|
||||||
|
|
||||||
|
export const getDayDetail = (date: string) =>
|
||||||
|
client.get<DayDetail>('/calendar/day', { params: { date } }).then(r => r.data);
|
||||||
|
|
||||||
|
export const getNote = (date: string) =>
|
||||||
|
client.get<DailyNote>(`/calendar/notes/${date}`).then(r => r.data);
|
||||||
|
|
||||||
|
export const upsertNote = (date: string, data: { content: string; mood?: string; energy_level?: number }) =>
|
||||||
|
client.put<DailyNote>(`/calendar/notes/${date}`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getEvents = (start: string, end: string) =>
|
||||||
|
client.get<CalendarEvent[]>('/calendar/events', { params: { start, end } }).then(r => r.data);
|
||||||
|
|
||||||
|
export const createEvent = (data: Omit<CalendarEvent, 'id' | 'created_at'>) =>
|
||||||
|
client.post<CalendarEvent>('/calendar/events', data).then(r => r.data);
|
||||||
|
|
||||||
|
export const updateEvent = (id: number, data: Partial<Omit<CalendarEvent, 'id' | 'date' | 'created_at'>>) =>
|
||||||
|
client.put<CalendarEvent>(`/calendar/events/${id}`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const deleteEvent = (id: number) =>
|
||||||
|
client.delete(`/calendar/events/${id}`);
|
||||||
14
frontend/src/api/health.ts
Normal file
14
frontend/src/api/health.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { HealthGoal, HealthGoalCreate, HealthMetric, HealthMetricCreate } from '../types/health';
|
||||||
|
|
||||||
|
export const getMetrics = (limit = 100) =>
|
||||||
|
client.get<HealthMetric[]>('/health/metrics', { params: { limit } }).then(r => r.data);
|
||||||
|
|
||||||
|
export const createMetric = (data: HealthMetricCreate) =>
|
||||||
|
client.post<HealthMetric>('/health/metrics', data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getGoals = () =>
|
||||||
|
client.get<HealthGoal[]>('/health/goals').then(r => r.data);
|
||||||
|
|
||||||
|
export const createGoal = (data: HealthGoalCreate) =>
|
||||||
|
client.post<HealthGoal>('/health/goals', data).then(r => r.data);
|
||||||
51
frontend/src/api/kettlebell.ts
Normal file
51
frontend/src/api/kettlebell.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { KettlebellSession, KettlebellSetLog } from '../types/kettlebell';
|
||||||
|
|
||||||
|
export interface GenerateSessionRequest {
|
||||||
|
focus: string;
|
||||||
|
duration_minutes: number;
|
||||||
|
available_weights: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogSetRequest {
|
||||||
|
exercise_order: number;
|
||||||
|
set_number: number;
|
||||||
|
actual_reps: number;
|
||||||
|
actual_weight_kg: number;
|
||||||
|
actual_duration_seconds: number;
|
||||||
|
perceived_effort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteSessionRequest {
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateSession = (data: GenerateSessionRequest) =>
|
||||||
|
client.post<KettlebellSession>('/kettlebell/generate', data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getSessions = () =>
|
||||||
|
client.get<KettlebellSession[]>('/kettlebell/').then(r => r.data);
|
||||||
|
|
||||||
|
export const getSession = (id: number) =>
|
||||||
|
client.get<KettlebellSession>(`/kettlebell/${id}`).then(r => r.data);
|
||||||
|
|
||||||
|
export const startSession = (id: number) =>
|
||||||
|
client.patch<KettlebellSession>(`/kettlebell/${id}/start`).then(r => r.data);
|
||||||
|
|
||||||
|
export const logSet = (id: number, data: LogSetRequest) =>
|
||||||
|
client.post<KettlebellSetLog>(`/kettlebell/${id}/sets`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getSets = (id: number) =>
|
||||||
|
client.get<KettlebellSetLog[]>(`/kettlebell/${id}/sets`).then(r => r.data);
|
||||||
|
|
||||||
|
export const completeSession = (id: number, data: CompleteSessionRequest = {}) =>
|
||||||
|
client.patch<KettlebellSession>(`/kettlebell/${id}/complete`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const retrySession = (id: number) =>
|
||||||
|
client.post<KettlebellSession>(`/kettlebell/${id}/retry`).then(r => r.data);
|
||||||
|
|
||||||
|
export const abandonSession = (id: number) =>
|
||||||
|
client.patch<KettlebellSession>(`/kettlebell/${id}/abandon`).then(r => r.data);
|
||||||
|
|
||||||
|
export const deleteSession = (id: number) =>
|
||||||
|
client.delete(`/kettlebell/${id}`);
|
||||||
27
frontend/src/api/nutrition.ts
Normal file
27
frontend/src/api/nutrition.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { FoodLog, FoodLogCreate, NutritionalInfo, NutritionSummary } from '../types/nutrition';
|
||||||
|
|
||||||
|
export const analyzeFoodText = (description: string) =>
|
||||||
|
client.post<NutritionalInfo>('/nutrition/analyze', { description }).then(r => r.data);
|
||||||
|
|
||||||
|
export const analyzeFoodImage = (file: File, description = '') => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (description) formData.append('description', description);
|
||||||
|
return client
|
||||||
|
.post<NutritionalInfo>('/nutrition/analyze/image', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logFood = (data: FoodLogCreate) =>
|
||||||
|
client.post<FoodLog>('/nutrition/log', data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getLogs = (skip = 0, limit = 100) =>
|
||||||
|
client.get<FoodLog[]>('/nutrition/logs', { params: { skip, limit } }).then(r => r.data);
|
||||||
|
|
||||||
|
export const getNutritionSummary = (date?: string) =>
|
||||||
|
client
|
||||||
|
.get<NutritionSummary>('/nutrition/summary', { params: date ? { date } : undefined })
|
||||||
|
.then(r => r.data);
|
||||||
8
frontend/src/api/plans.ts
Normal file
8
frontend/src/api/plans.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { Plan, PlanRequest } from '../types/plans';
|
||||||
|
|
||||||
|
export const getPlans = () =>
|
||||||
|
client.get<Plan[]>('/plans/').then(r => r.data);
|
||||||
|
|
||||||
|
export const generatePlan = (data: PlanRequest) =>
|
||||||
|
client.post<Plan>('/plans/generate', data).then(r => r.data);
|
||||||
32
frontend/src/api/push.ts
Normal file
32
frontend/src/api/push.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface PushSubscribeRequest {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
reminder_hour: number;
|
||||||
|
reminder_minute: number;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVapidPublicKey = (): Promise<string> =>
|
||||||
|
client.get<{ public_key: string }>('/push/vapid-public-key').then((r) => r.data.public_key);
|
||||||
|
|
||||||
|
export const subscribePush = (data: PushSubscribeRequest): Promise<void> =>
|
||||||
|
client.post('/push/subscribe', data).then((r) => r.data);
|
||||||
|
|
||||||
|
export const unsubscribePush = (endpoint: string): Promise<void> =>
|
||||||
|
client.delete('/push/unsubscribe', { data: { endpoint } }).then((r) => r.data);
|
||||||
|
|
||||||
|
export const sendTestNotification = (): Promise<{ sent: number }> =>
|
||||||
|
client.post('/push/test').then((r) => r.data);
|
||||||
|
|
||||||
|
export function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = atob(base64);
|
||||||
|
const output = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
output[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
32
frontend/src/api/supplements.ts
Normal file
32
frontend/src/api/supplements.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {
|
||||||
|
Supplement,
|
||||||
|
SupplementCreate,
|
||||||
|
SupplementLog,
|
||||||
|
SupplementUpdate,
|
||||||
|
SupplementWithStatus,
|
||||||
|
} from '../types/supplement';
|
||||||
|
|
||||||
|
export const getSupplements = () =>
|
||||||
|
client.get<Supplement[]>('/supplements/').then(r => r.data);
|
||||||
|
|
||||||
|
export const createSupplement = (data: SupplementCreate) =>
|
||||||
|
client.post<Supplement>('/supplements/', data).then(r => r.data);
|
||||||
|
|
||||||
|
export const updateSupplement = (id: number, data: SupplementUpdate) =>
|
||||||
|
client.put<Supplement>(`/supplements/${id}`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const deleteSupplement = (id: number) =>
|
||||||
|
client.delete(`/supplements/${id}`);
|
||||||
|
|
||||||
|
export const logSupplement = (id: number, data: { dose_taken?: number; notes?: string } = {}) =>
|
||||||
|
client.post<SupplementLog>(`/supplements/${id}/log`, data).then(r => r.data);
|
||||||
|
|
||||||
|
export const getSupplementLogs = (params?: {
|
||||||
|
supplement_id?: number;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}) => client.get<SupplementLog[]>('/supplements/logs', { params }).then(r => r.data);
|
||||||
|
|
||||||
|
export const getTodaySupplements = () =>
|
||||||
|
client.get<SupplementWithStatus[]>('/supplements/today').then(r => r.data);
|
||||||
8
frontend/src/api/users.ts
Normal file
8
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { User, UserUpdate } from '../types/user';
|
||||||
|
|
||||||
|
export const getMe = () =>
|
||||||
|
client.get<User>('/users/me').then(r => r.data);
|
||||||
|
|
||||||
|
export const updateMe = (data: UserUpdate) =>
|
||||||
|
client.put<User>('/users/me', data).then(r => r.data);
|
||||||
47
frontend/src/components/ConfirmModal.tsx
Normal file
47
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
interface ConfirmModalProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
destructive = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onCancel} />
|
||||||
|
<div className="relative bg-surface border border-border rounded-2xl shadow-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-bold text-content">{title}</h2>
|
||||||
|
<p className="text-sm text-content-muted">{message}</p>
|
||||||
|
<div className="flex gap-3 justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 rounded-xl border border-border text-content text-sm hover:bg-base transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-colors ${
|
||||||
|
destructive
|
||||||
|
? 'bg-red-600 hover:bg-red-500 text-white'
|
||||||
|
: 'bg-primary hover:bg-primary/90 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,10 +14,13 @@ import {
|
|||||||
Utensils,
|
Utensils,
|
||||||
Heart,
|
Heart,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarDays,
|
||||||
User,
|
User,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Dumbbell,
|
||||||
|
Pill,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useTheme } from '../../context/ThemeContext'
|
import { useTheme } from '../../context/ThemeContext'
|
||||||
@@ -34,6 +37,9 @@ export function AppSidebar() {
|
|||||||
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
|
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
|
||||||
{ to: "/health", icon: Heart, label: "Health" },
|
{ to: "/health", icon: Heart, label: "Health" },
|
||||||
|
{ to: "/kettlebell", icon: Dumbbell, label: "Kettlebell" },
|
||||||
|
{ to: "/supplements", icon: Pill, label: "Supplements" },
|
||||||
|
{ to: "/calendar", icon: CalendarDays, label: "Calendar" },
|
||||||
{ to: "/plans", icon: Calendar, label: "Plans" },
|
{ to: "/plans", icon: Calendar, label: "Plans" },
|
||||||
{ to: "/profile", icon: User, label: "Profile" },
|
{ to: "/profile", icon: User, label: "Profile" },
|
||||||
]
|
]
|
||||||
|
|||||||
173
frontend/src/components/calendar/CalendarEventForm.tsx
Normal file
173
frontend/src/components/calendar/CalendarEventForm.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import type { CalendarEvent } from '../../types/calendar';
|
||||||
|
import { createEvent, updateEvent } from '../../api/calendar';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const COLOR_SWATCHES = [
|
||||||
|
{ label: 'Blue', value: 'blue', cls: 'bg-blue-500' },
|
||||||
|
{ label: 'Green', value: 'green', cls: 'bg-green-500' },
|
||||||
|
{ label: 'Red', value: 'red', cls: 'bg-red-500' },
|
||||||
|
{ label: 'Purple', value: 'purple', cls: 'bg-purple-500' },
|
||||||
|
{ label: 'Orange', value: 'orange', cls: 'bg-orange-500' },
|
||||||
|
{ label: 'Pink', value: 'pink', cls: 'bg-pink-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
existing?: CalendarEvent;
|
||||||
|
defaultType?: CalendarEvent['event_type'];
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarEventForm({ date, existing, defaultType = 'general', onSuccess, onCancel }: Props) {
|
||||||
|
const [title, setTitle] = useState(existing?.title ?? '');
|
||||||
|
const [description, setDescription] = useState(existing?.description ?? '');
|
||||||
|
const [eventType, setEventType] = useState<CalendarEvent['event_type']>(existing?.event_type ?? defaultType);
|
||||||
|
const [color, setColor] = useState(existing?.color ?? '');
|
||||||
|
const [startTime, setStartTime] = useState(existing?.start_time ?? '');
|
||||||
|
const [isCompleted, setIsCompleted] = useState(existing?.is_completed ?? false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error('Title is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (existing?.id) {
|
||||||
|
await updateEvent(existing.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
event_type: eventType,
|
||||||
|
color: color || undefined,
|
||||||
|
start_time: startTime || undefined,
|
||||||
|
is_completed: isCompleted,
|
||||||
|
});
|
||||||
|
toast.success('Event updated');
|
||||||
|
} else {
|
||||||
|
await createEvent({
|
||||||
|
date,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
event_type: eventType,
|
||||||
|
color: color || undefined,
|
||||||
|
start_time: startTime || undefined,
|
||||||
|
is_completed: isCompleted,
|
||||||
|
});
|
||||||
|
toast.success('Event created');
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save event');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||||
|
Title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Event title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={eventType}
|
||||||
|
onChange={e => setEventType(e.target.value as CalendarEvent['event_type'])}
|
||||||
|
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="general">General</option>
|
||||||
|
<option value="workout">Workout</option>
|
||||||
|
<option value="supplement">Supplement</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Start Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={e => setStartTime(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor('')}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 bg-zinc-200 dark:bg-zinc-600 ${!color ? 'border-primary' : 'border-transparent'}`}
|
||||||
|
title="None"
|
||||||
|
/>
|
||||||
|
{COLOR_SWATCHES.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(s.value)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 ${s.cls} ${color === s.value ? 'border-primary' : 'border-transparent'}`}
|
||||||
|
title={s.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="is-completed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isCompleted}
|
||||||
|
onChange={e => setIsCompleted(e.target.checked)}
|
||||||
|
className="rounded border-zinc-300 dark:border-zinc-600 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is-completed" className="text-sm text-zinc-700 dark:text-zinc-300">Mark as completed</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : existing ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 rounded-md border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
frontend/src/components/calendar/DayModal.tsx
Normal file
420
frontend/src/components/calendar/DayModal.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Dumbbell, Pill, FileText, Plus, Pencil, Trash2, CheckCircle2, Circle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { DayDetail, CalendarEvent as CalEvent } from '../../types/calendar';
|
||||||
|
import { upsertNote, deleteEvent } from '../../api/calendar';
|
||||||
|
import CalendarEventForm from './CalendarEventForm';
|
||||||
|
|
||||||
|
const MOOD_OPTIONS = [
|
||||||
|
{ value: 'great', emoji: '😄', label: 'Great' },
|
||||||
|
{ value: 'good', emoji: '🙂', label: 'Good' },
|
||||||
|
{ value: 'okay', emoji: '😐', label: 'Okay' },
|
||||||
|
{ value: 'bad', emoji: '😕', label: 'Bad' },
|
||||||
|
{ value: 'awful', emoji: '😞', label: 'Awful' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorClass(color?: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
green: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||||
|
red: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||||
|
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||||
|
pink: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||||
|
};
|
||||||
|
return color && map[color] ? map[color] : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workout Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WorkoutTab({ detail, onRefresh }: { detail: DayDetail; onRefresh: () => void }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editEvent, setEditEvent] = useState<CalEvent | null>(null);
|
||||||
|
|
||||||
|
const workoutEvents = detail.events.filter(e => e.event_type === 'workout');
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteEvent(id);
|
||||||
|
toast.success('Event deleted');
|
||||||
|
onRefresh();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showForm || editEvent) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">
|
||||||
|
{editEvent ? 'Edit Event' : 'Add Workout Event'}
|
||||||
|
</h3>
|
||||||
|
<CalendarEventForm
|
||||||
|
date={detail.date}
|
||||||
|
existing={editEvent ?? undefined}
|
||||||
|
defaultType="workout"
|
||||||
|
onSuccess={() => { setShowForm(false); setEditEvent(null); onRefresh(); }}
|
||||||
|
onCancel={() => { setShowForm(false); setEditEvent(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Kettlebell sessions */}
|
||||||
|
{detail.kettlebell_sessions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">
|
||||||
|
Kettlebell Sessions
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detail.kettlebell_sessions.map(kb => (
|
||||||
|
<div key={kb.id} className="rounded-lg border border-zinc-200 dark:border-zinc-700 p-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{kb.title}</p>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
{kb.focus} · {kb.total_duration_min} min · {kb.difficulty}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
kb.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
|
||||||
|
}`}>
|
||||||
|
{kb.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workout events */}
|
||||||
|
{workoutEvents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">
|
||||||
|
Workout Events
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workoutEvents.map(ev => (
|
||||||
|
<div key={ev.id} className={`rounded-lg p-3 ${colorClass(ev.color)}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{ev.is_completed ? <CheckCircle2 size={14} /> : <Circle size={14} />}
|
||||||
|
<p className="text-sm font-medium truncate">{ev.title}</p>
|
||||||
|
</div>
|
||||||
|
{ev.description && (
|
||||||
|
<p className="text-xs mt-0.5 opacity-75 truncate">{ev.description}</p>
|
||||||
|
)}
|
||||||
|
{ev.start_time && (
|
||||||
|
<p className="text-xs mt-0.5 opacity-60">{ev.start_time}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button onClick={() => setEditEvent(ev)} className="p-1 rounded hover:opacity-75 transition-opacity">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => ev.id && handleDelete(ev.id)} className="p-1 rounded hover:opacity-75 transition-opacity">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.kettlebell_sessions.length === 0 && workoutEvents.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-400 dark:text-zinc-500 text-center py-6">No workouts logged for this day.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={15} /> Add workout event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supplements Tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SupplementsTab({ detail }: { detail: DayDetail }) {
|
||||||
|
const taken = detail.supplements.filter(s => s.taken_today);
|
||||||
|
const notTaken = detail.supplements.filter(s => !s.taken_today);
|
||||||
|
const compliance = detail.supplements.length > 0
|
||||||
|
? Math.round((taken.length / detail.supplements.length) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{compliance !== null && (
|
||||||
|
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800/50 p-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400 mb-1">
|
||||||
|
<span>Compliance</span>
|
||||||
|
<span>{taken.length}/{detail.supplements.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${compliance >= 80 ? 'bg-green-500' : compliance >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||||
|
style={{ width: `${compliance}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{compliance}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.supplements.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-400 dark:text-zinc-500 text-center py-6">No active supplements.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{taken.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">Taken</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{taken.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-900/20 px-3 py-2">
|
||||||
|
<CheckCircle2 size={15} className="text-green-500 shrink-0" />
|
||||||
|
<span className="text-sm text-zinc-900 dark:text-zinc-100">{s.name}</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400 ml-auto">{s.dosage} {s.unit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notTaken.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">Not Taken</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{notTaken.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2 rounded-md bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
|
||||||
|
<Circle size={15} className="text-zinc-400 shrink-0" />
|
||||||
|
<span className="text-sm text-zinc-500 dark:text-zinc-400">{s.name}</span>
|
||||||
|
<span className="text-xs text-zinc-400 ml-auto">{s.dosage} {s.unit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes Tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NotesTab({ detail, onRefresh }: { detail: DayDetail; onRefresh: () => void }) {
|
||||||
|
const [content, setContent] = useState(detail.note?.content ?? '');
|
||||||
|
const [mood, setMood] = useState(detail.note?.mood ?? '');
|
||||||
|
const [energyLevel, setEnergyLevel] = useState(detail.note?.energy_level ?? 5);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const save = useCallback(async (c: string, m: string, e: number) => {
|
||||||
|
setSaveStatus('saving');
|
||||||
|
try {
|
||||||
|
await upsertNote(detail.date, {
|
||||||
|
content: c,
|
||||||
|
mood: m || undefined,
|
||||||
|
energy_level: e,
|
||||||
|
});
|
||||||
|
setSaveStatus('saved');
|
||||||
|
onRefresh();
|
||||||
|
} catch {
|
||||||
|
setSaveStatus('idle');
|
||||||
|
toast.error('Failed to save note');
|
||||||
|
}
|
||||||
|
}, [detail.date, onRefresh]);
|
||||||
|
|
||||||
|
const scheduleAutosave = (c: string, m: string, e: number) => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => save(c, m, e), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleContentChange = (val: string) => {
|
||||||
|
if (val.length > 10000) return;
|
||||||
|
setContent(val);
|
||||||
|
setSaveStatus('idle');
|
||||||
|
scheduleAutosave(val, mood, energyLevel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoodChange = (val: string) => {
|
||||||
|
const newMood = mood === val ? '' : val;
|
||||||
|
setMood(newMood);
|
||||||
|
setSaveStatus('idle');
|
||||||
|
scheduleAutosave(content, newMood, energyLevel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnergyChange = (val: number) => {
|
||||||
|
setEnergyLevel(val);
|
||||||
|
setSaveStatus('idle');
|
||||||
|
scheduleAutosave(content, mood, val);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Notes</label>
|
||||||
|
<span className="text-xs text-zinc-400">
|
||||||
|
{saveStatus === 'saving' && 'Saving...'}
|
||||||
|
{saveStatus === 'saved' && <span className="text-green-500">Saved</span>}
|
||||||
|
{saveStatus === 'idle' && `${content.length}/10000`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={e => handleContentChange(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
maxLength={10000}
|
||||||
|
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||||
|
placeholder="How did your day go? Any notes about your health, workouts, or wellbeing..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2 block">Mood</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{MOOD_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoodChange(opt.value)}
|
||||||
|
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 text-xs transition-colors border-2 ${
|
||||||
|
mood === opt.value
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-transparent hover:border-zinc-300 dark:hover:border-zinc-600'
|
||||||
|
}`}
|
||||||
|
title={opt.label}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{opt.emoji}</span>
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Energy Level</label>
|
||||||
|
<span className="text-sm font-bold text-primary">{energyLevel}/10</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={energyLevel}
|
||||||
|
onChange={e => handleEnergyChange(Number(e.target.value))}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-zinc-400 mt-1">
|
||||||
|
<span>Low</span><span>High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DayModal ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Tab = 'workout' | 'supplements' | 'notes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
detail: DayDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayModal({ date, detail, loading, onClose, onRefresh }: Props) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('notes');
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'workout', label: 'Workout', icon: <Dumbbell size={15} /> },
|
||||||
|
{ id: 'supplements', label: 'Supplements', icon: <Pill size={15} /> },
|
||||||
|
{ id: 'notes', label: 'Notes', icon: <FileText size={15} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="relative z-10 w-full sm:max-w-lg sm:mx-4 bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{formatDate(date)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors rounded-md p-1"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-zinc-200 dark:border-zinc-800 px-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && detail && (
|
||||||
|
<>
|
||||||
|
{activeTab === 'workout' && <WorkoutTab detail={detail} onRefresh={onRefresh} />}
|
||||||
|
{activeTab === 'supplements' && <SupplementsTab detail={detail} />}
|
||||||
|
{activeTab === 'notes' && <NotesTab detail={detail} onRefresh={onRefresh} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!loading && !detail && (
|
||||||
|
<p className="text-sm text-zinc-400 text-center py-12">Failed to load day details.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/kettlebell/ElapsedTimer.tsx
Normal file
55
frontend/src/components/kettlebell/ElapsedTimer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
interface ElapsedTimerProps {
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElapsedTimer({ seconds }: ElapsedTimerProps) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const radius = 70;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="relative w-48 h-48">
|
||||||
|
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
|
||||||
|
<circle
|
||||||
|
cx="80" cy="80" r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
className="text-zinc-200 dark:text-zinc-700"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="80" cy="80" r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={circumference * 0.25}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-4xl font-mono font-bold tabular-nums text-content">
|
||||||
|
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/kettlebell/ProgressBar.tsx
Normal file
14
frontend/src/components/kettlebell/ProgressBar.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
interface ProgressBarProps {
|
||||||
|
exerciseIdx: number;
|
||||||
|
totalExercises: number;
|
||||||
|
setIdx: number;
|
||||||
|
totalSets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ exerciseIdx, totalExercises, setIdx, totalSets }: ProgressBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-content-muted text-center">
|
||||||
|
Exercise {exerciseIdx + 1}/{totalExercises} · Set {setIdx + 1}/{totalSets}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/kettlebell/RestCountdown.tsx
Normal file
46
frontend/src/components/kettlebell/RestCountdown.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
interface RestCountdownProps {
|
||||||
|
remaining: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestCountdown({ remaining, total }: RestCountdownProps) {
|
||||||
|
const radius = 54;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const progress = total > 0 ? remaining / total : 0;
|
||||||
|
const dashOffset = circumference * (1 - progress);
|
||||||
|
|
||||||
|
const m = Math.floor(remaining / 60);
|
||||||
|
const s = remaining % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="relative w-36 h-36">
|
||||||
|
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||||
|
<circle
|
||||||
|
cx="60" cy="60" r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
className="text-zinc-200 dark:text-zinc-700"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="60" cy="60" r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
className="text-primary transition-all duration-1000"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-mono font-bold tabular-nums text-content">
|
||||||
|
{pad(m)}:{pad(s)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/kettlebell/SessionSummary.tsx
Normal file
36
frontend/src/components/kettlebell/SessionSummary.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { KettlebellSetLog } from '../../types/kettlebell';
|
||||||
|
import { ElapsedTimer } from './ElapsedTimer';
|
||||||
|
|
||||||
|
interface SessionSummaryProps {
|
||||||
|
totalElapsed: number;
|
||||||
|
logged: KettlebellSetLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSummary({ totalElapsed, logged }: SessionSummaryProps) {
|
||||||
|
const avgRpe = logged.length > 0
|
||||||
|
? Math.round(logged.reduce((sum, s) => sum + s.perceived_effort, 0) / logged.length * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 py-12 px-6">
|
||||||
|
<div className="text-6xl">🎉</div>
|
||||||
|
<h1 className="text-3xl font-bold text-content">Workout Complete!</h1>
|
||||||
|
<div className="grid grid-cols-3 gap-6 w-full max-w-sm">
|
||||||
|
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">
|
||||||
|
<ElapsedTimer seconds={totalElapsed} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-content-muted mt-1">Total Time</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">{logged.length}</div>
|
||||||
|
<div className="text-xs text-content-muted mt-1">Sets Done</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">{avgRpe}</div>
|
||||||
|
<div className="text-xs text-content-muted mt-1">Avg RPE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/components/kettlebell/SetLogger.tsx
Normal file
101
frontend/src/components/kettlebell/SetLogger.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ExerciseBlock } from '../../types/kettlebell';
|
||||||
|
|
||||||
|
interface SetLoggerProps {
|
||||||
|
exercise: ExerciseBlock;
|
||||||
|
reps: number;
|
||||||
|
weightKg: number;
|
||||||
|
effort: number;
|
||||||
|
onRepsChange: (v: number) => void;
|
||||||
|
onWeightChange: (v: number) => void;
|
||||||
|
onEffortChange: (v: number) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stepper({ label, value, onChange, step = 1, min = 0 }: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
step?: number;
|
||||||
|
min?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-content-muted w-24">{label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(Math.max(min, value - step))}
|
||||||
|
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-14 text-center text-xl font-bold text-content tabular-nums">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(value + step)}
|
||||||
|
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-content-muted w-24">Effort</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => (
|
||||||
|
<button
|
||||||
|
key={dot}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(dot)}
|
||||||
|
className={`w-6 h-6 rounded-full transition-colors ${dot <= value
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-zinc-200 dark:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
aria-label={`RPE ${dot}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="ml-2 text-sm text-content-muted tabular-nums">{value}/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetLogger({
|
||||||
|
exercise,
|
||||||
|
reps,
|
||||||
|
weightKg,
|
||||||
|
effort,
|
||||||
|
onRepsChange,
|
||||||
|
onWeightChange,
|
||||||
|
onEffortChange,
|
||||||
|
onComplete,
|
||||||
|
}: SetLoggerProps) {
|
||||||
|
const isTimed = exercise.reps === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 px-6 py-4">
|
||||||
|
{isTimed ? (
|
||||||
|
<Stepper label="Duration (s)" value={reps} onChange={onRepsChange} step={5} />
|
||||||
|
) : (
|
||||||
|
<Stepper label="Reps done" value={reps} onChange={onRepsChange} />
|
||||||
|
)}
|
||||||
|
<Stepper label="Weight (kg)" value={weightKg} onChange={onWeightChange} step={0.5} />
|
||||||
|
<RpeDots value={effort} onChange={onEffortChange} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onComplete}
|
||||||
|
className="w-full min-h-[72px] rounded-2xl bg-primary text-white text-xl font-bold active:scale-95 transition-transform mt-2"
|
||||||
|
>
|
||||||
|
COMPLETE SET
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
532
frontend/src/pages/ActiveSession.tsx
Normal file
532
frontend/src/pages/ActiveSession.tsx
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { useEffect, useReducer, useCallback, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { getSession, getSets, startSession, logSet, completeSession, abandonSession } from '../api/kettlebell';
|
||||||
|
import type { KettlebellSession, KettlebellSetLog, ExerciseBlock } from '../types/kettlebell';
|
||||||
|
import { ElapsedTimer, ElapsedTimerCircle } from '../components/kettlebell/ElapsedTimer';
|
||||||
|
import { RestCountdown } from '../components/kettlebell/RestCountdown';
|
||||||
|
import { SetLogger } from '../components/kettlebell/SetLogger';
|
||||||
|
import { ProgressBar } from '../components/kettlebell/ProgressBar';
|
||||||
|
import { SessionSummary } from '../components/kettlebell/SessionSummary';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
|
||||||
|
// --------------- State Machine ---------------
|
||||||
|
|
||||||
|
interface LastSetStats {
|
||||||
|
duration: number;
|
||||||
|
restTaken: number | null;
|
||||||
|
reps: number;
|
||||||
|
weightKg: number;
|
||||||
|
effort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionPhase =
|
||||||
|
| { phase: 'loading' }
|
||||||
|
| { phase: 'planning'; session: KettlebellSession }
|
||||||
|
| { phase: 'active'; session: KettlebellSession; exerciseIdx: number; setIdx: number; elapsed: number; setElapsed: number; lastSetStats: LastSetStats | null; logged: KettlebellSetLog[]; reps: number; weightKg: number; effort: number }
|
||||||
|
| { phase: 'resting'; session: KettlebellSession; exerciseIdx: number; nextSetIdx: number; restRemaining: number; totalRest: number; elapsed: number; pendingSet: { duration: number; reps: number; weightKg: number; effort: number }; logged: KettlebellSetLog[] }
|
||||||
|
| { phase: 'complete'; session: KettlebellSession; totalElapsed: number; logged: KettlebellSetLog[] };
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'LOAD'; session: KettlebellSession; existingLogs: KettlebellSetLog[] }
|
||||||
|
| { type: 'START' }
|
||||||
|
| { type: 'TICK_ELAPSED' }
|
||||||
|
| { type: 'TICK_REST' }
|
||||||
|
| { type: 'SET_REPS'; value: number }
|
||||||
|
| { type: 'SET_WEIGHT'; value: number }
|
||||||
|
| { type: 'SET_EFFORT'; value: number }
|
||||||
|
| { type: 'COMPLETE_SET'; log: KettlebellSetLog }
|
||||||
|
| { type: 'SKIP_REST' }
|
||||||
|
| { type: 'FINISH' };
|
||||||
|
|
||||||
|
function getExercises(session: KettlebellSession): ExerciseBlock[] {
|
||||||
|
return session.exercises?.exercises ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialSetValues(exercise: ExerciseBlock) {
|
||||||
|
return {
|
||||||
|
reps: exercise.reps > 0 ? exercise.reps : exercise.duration_seconds,
|
||||||
|
weightKg: exercise.weight_kg,
|
||||||
|
effort: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: SessionPhase, action: Action): SessionPhase {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'LOAD': {
|
||||||
|
const { session, existingLogs } = action;
|
||||||
|
if (session.status === 'completed') {
|
||||||
|
const elapsed = session.started_at && session.completed_at
|
||||||
|
? Math.round((new Date(session.completed_at).getTime() - new Date(session.started_at).getTime()) / 1000)
|
||||||
|
: 0;
|
||||||
|
return { phase: 'complete', session, totalElapsed: elapsed, logged: existingLogs };
|
||||||
|
}
|
||||||
|
if (session.status === 'in_progress') {
|
||||||
|
const exercises = getExercises(session);
|
||||||
|
const elapsed = session.started_at
|
||||||
|
? Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000)
|
||||||
|
: 0;
|
||||||
|
// Rehydrate: find next incomplete set
|
||||||
|
let exerciseIdx = 0;
|
||||||
|
let setIdx = 0;
|
||||||
|
for (let ei = 0; ei < exercises.length; ei++) {
|
||||||
|
for (let si = 0; si < exercises[ei].sets; si++) {
|
||||||
|
const done = existingLogs.some(l => l.exercise_order === exercises[ei].order && l.set_number === si + 1);
|
||||||
|
if (!done) {
|
||||||
|
exerciseIdx = ei;
|
||||||
|
setIdx = si;
|
||||||
|
const ex = exercises[ei];
|
||||||
|
return {
|
||||||
|
phase: 'active', session, exerciseIdx, setIdx, elapsed, setElapsed: 0, lastSetStats: null, logged: existingLogs,
|
||||||
|
...initialSetValues(ex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All sets done but session not completed
|
||||||
|
return { phase: 'complete', session, totalElapsed: elapsed, logged: existingLogs };
|
||||||
|
}
|
||||||
|
return { phase: 'planning', session };
|
||||||
|
}
|
||||||
|
case 'START': {
|
||||||
|
if (state.phase !== 'planning') return state;
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
if (exercises.length === 0) return state;
|
||||||
|
const ex = exercises[0];
|
||||||
|
return {
|
||||||
|
phase: 'active',
|
||||||
|
session: { ...state.session, status: 'in_progress' },
|
||||||
|
exerciseIdx: 0,
|
||||||
|
setIdx: 0,
|
||||||
|
elapsed: 0,
|
||||||
|
setElapsed: 0,
|
||||||
|
lastSetStats: null,
|
||||||
|
logged: [],
|
||||||
|
...initialSetValues(ex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'TICK_ELAPSED': {
|
||||||
|
if (state.phase === 'active') return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 };
|
||||||
|
if (state.phase === 'resting') return { ...state, elapsed: state.elapsed + 1 };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
case 'TICK_REST': {
|
||||||
|
if (state.phase !== 'resting') return state;
|
||||||
|
if (state.restRemaining <= 1) {
|
||||||
|
// Auto-advance to next set
|
||||||
|
return advanceAfterRest(state);
|
||||||
|
}
|
||||||
|
return { ...state, restRemaining: state.restRemaining - 1 };
|
||||||
|
}
|
||||||
|
case 'SET_REPS': {
|
||||||
|
if (state.phase !== 'active') return state;
|
||||||
|
return { ...state, reps: action.value };
|
||||||
|
}
|
||||||
|
case 'SET_WEIGHT': {
|
||||||
|
if (state.phase !== 'active') return state;
|
||||||
|
return { ...state, weightKg: action.value };
|
||||||
|
}
|
||||||
|
case 'SET_EFFORT': {
|
||||||
|
if (state.phase !== 'active') return state;
|
||||||
|
return { ...state, effort: action.value };
|
||||||
|
}
|
||||||
|
case 'COMPLETE_SET': {
|
||||||
|
if (state.phase !== 'active') return state;
|
||||||
|
const newLogged = [...state.logged, action.log];
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
const totalRest = exercise.rest_seconds;
|
||||||
|
|
||||||
|
// Check if there's a next set or exercise
|
||||||
|
const hasNextSet = state.setIdx + 1 < exercise.sets;
|
||||||
|
const hasNextExercise = state.exerciseIdx + 1 < exercises.length;
|
||||||
|
|
||||||
|
if (!hasNextSet && !hasNextExercise) {
|
||||||
|
// Last set of last exercise — go to complete
|
||||||
|
return {
|
||||||
|
phase: 'complete',
|
||||||
|
session: state.session,
|
||||||
|
totalElapsed: state.elapsed,
|
||||||
|
logged: newLogged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSet = { duration: state.setElapsed, reps: state.reps, weightKg: state.weightKg, effort: state.effort };
|
||||||
|
|
||||||
|
if (totalRest > 0) {
|
||||||
|
return {
|
||||||
|
phase: 'resting',
|
||||||
|
session: state.session,
|
||||||
|
exerciseIdx: state.exerciseIdx,
|
||||||
|
nextSetIdx: state.setIdx + 1,
|
||||||
|
restRemaining: totalRest,
|
||||||
|
totalRest,
|
||||||
|
elapsed: state.elapsed,
|
||||||
|
pendingSet,
|
||||||
|
logged: newLogged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return advanceFromActive(state, newLogged, { ...pendingSet, restTaken: null });
|
||||||
|
}
|
||||||
|
case 'SKIP_REST': {
|
||||||
|
if (state.phase !== 'resting') return state;
|
||||||
|
return advanceAfterRest(state);
|
||||||
|
}
|
||||||
|
case 'FINISH':
|
||||||
|
return state;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceFromActive(state: Extract<SessionPhase, { phase: 'active' }>, newLogged: KettlebellSetLog[], lastSetStats: LastSetStats): SessionPhase {
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
if (state.setIdx + 1 < exercise.sets) {
|
||||||
|
return { ...state, setIdx: state.setIdx + 1, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(exercise) };
|
||||||
|
}
|
||||||
|
if (state.exerciseIdx + 1 < exercises.length) {
|
||||||
|
const nextEx = exercises[state.exerciseIdx + 1];
|
||||||
|
return { ...state, exerciseIdx: state.exerciseIdx + 1, setIdx: 0, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(nextEx) };
|
||||||
|
}
|
||||||
|
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: newLogged };
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceAfterRest(state: Extract<SessionPhase, { phase: 'resting' }>): SessionPhase {
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
const restTaken = state.totalRest - state.restRemaining;
|
||||||
|
const lastSetStats: LastSetStats = { ...state.pendingSet, restTaken };
|
||||||
|
if (state.nextSetIdx < exercise.sets) {
|
||||||
|
return {
|
||||||
|
phase: 'active',
|
||||||
|
session: state.session,
|
||||||
|
exerciseIdx: state.exerciseIdx,
|
||||||
|
setIdx: state.nextSetIdx,
|
||||||
|
elapsed: state.elapsed,
|
||||||
|
setElapsed: 0,
|
||||||
|
lastSetStats,
|
||||||
|
logged: state.logged,
|
||||||
|
...initialSetValues(exercise),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.exerciseIdx + 1 < exercises.length) {
|
||||||
|
const nextEx = exercises[state.exerciseIdx + 1];
|
||||||
|
return {
|
||||||
|
phase: 'active',
|
||||||
|
session: state.session,
|
||||||
|
exerciseIdx: state.exerciseIdx + 1,
|
||||||
|
setIdx: 0,
|
||||||
|
elapsed: state.elapsed,
|
||||||
|
setElapsed: 0,
|
||||||
|
lastSetStats,
|
||||||
|
logged: state.logged,
|
||||||
|
...initialSetValues(nextEx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: state.logged };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- Component ---------------
|
||||||
|
|
||||||
|
export default function ActiveSession() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [state, dispatch] = useReducer(reducer, { phase: 'loading' });
|
||||||
|
|
||||||
|
// Load session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
Promise.all([getSession(Number(id)), getSets(Number(id))])
|
||||||
|
.then(([session, logs]) => dispatch({ type: 'LOAD', session, existingLogs: logs }))
|
||||||
|
.catch(() => navigate('/kettlebell'));
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
// Elapsed timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'active' && state.phase !== 'resting') return;
|
||||||
|
const interval = setInterval(() => dispatch({ type: 'TICK_ELAPSED' }), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [state.phase]);
|
||||||
|
|
||||||
|
// Rest countdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'resting') return;
|
||||||
|
const interval = setInterval(() => dispatch({ type: 'TICK_REST' }), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [state.phase]);
|
||||||
|
|
||||||
|
// Start session on server
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
if (!id || state.phase !== 'planning') return;
|
||||||
|
await startSession(Number(id));
|
||||||
|
dispatch({ type: 'START' });
|
||||||
|
}, [id, state.phase]);
|
||||||
|
|
||||||
|
// Complete set: log to server, then advance
|
||||||
|
const handleCompleteSet = useCallback(async () => {
|
||||||
|
if (state.phase !== 'active' || !id) return;
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
const isTimed = exercise.reps === 0;
|
||||||
|
try {
|
||||||
|
const log = await logSet(Number(id), {
|
||||||
|
exercise_order: exercise.order,
|
||||||
|
set_number: state.setIdx + 1,
|
||||||
|
actual_reps: isTimed ? 0 : state.reps,
|
||||||
|
actual_weight_kg: state.weightKg,
|
||||||
|
actual_duration_seconds: isTimed ? state.reps : 0,
|
||||||
|
perceived_effort: state.effort,
|
||||||
|
});
|
||||||
|
dispatch({ type: 'COMPLETE_SET', log });
|
||||||
|
|
||||||
|
// Check if this was the last set of last exercise
|
||||||
|
const hasNextSet = state.setIdx + 1 < exercise.sets;
|
||||||
|
const hasNextExercise = state.exerciseIdx + 1 < exercises.length;
|
||||||
|
if (!hasNextSet && !hasNextExercise) {
|
||||||
|
await completeSession(Number(id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to log set', err);
|
||||||
|
alert('Failed to log set. Please try again.');
|
||||||
|
}
|
||||||
|
}, [id, state]);
|
||||||
|
|
||||||
|
const [showAbandonModal, setShowAbandonModal] = useState(false);
|
||||||
|
|
||||||
|
const handleAbandon = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await abandonSession(Number(id));
|
||||||
|
navigate('/kettlebell');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to abandon session', err);
|
||||||
|
}
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
if (state.phase === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base flex items-center justify-center">
|
||||||
|
<p className="text-content-muted">Loading session...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === 'planning') {
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base flex flex-col">
|
||||||
|
<div className="flex items-center gap-4 px-4 py-4 border-b border-border">
|
||||||
|
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h1 className="font-bold text-content flex-1 truncate">{state.session.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
|
||||||
|
<p className="text-content-muted text-sm capitalize">
|
||||||
|
{state.session.focus} · {state.session.difficulty} · {state.session.total_duration_min} min
|
||||||
|
</p>
|
||||||
|
{exercises.map((ex, i) => (
|
||||||
|
<div key={i} className="bg-surface border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-content">{ex.order}. {ex.name}</p>
|
||||||
|
<p className="text-sm text-content-muted">{ex.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-content-muted text-right ml-4 shrink-0">
|
||||||
|
<p>{ex.sets} × {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}</p>
|
||||||
|
<p>{ex.weight_kg} kg</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-primary mt-2 italic">"{ex.coaching_tip}"</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-8 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
|
className="w-full min-h-[72px] rounded-2xl bg-primary text-white text-xl font-bold active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
START WORKOUT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === 'active') {
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base flex flex-col">
|
||||||
|
{showAbandonModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Cancel Session"
|
||||||
|
message="Cancel this session? Progress logged so far will be saved but the session will be marked as abandoned."
|
||||||
|
confirmLabel="Cancel Session"
|
||||||
|
destructive
|
||||||
|
onConfirm={handleAbandon}
|
||||||
|
onCancel={() => setShowAbandonModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sticky top bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface sticky top-0 z-10">
|
||||||
|
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<ProgressBar
|
||||||
|
exerciseIdx={state.exerciseIdx}
|
||||||
|
totalExercises={exercises.length}
|
||||||
|
setIdx={state.setIdx}
|
||||||
|
totalSets={exercise.sets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exercise info */}
|
||||||
|
<div className="px-6 py-6 border-b border-border">
|
||||||
|
<h2 className="text-2xl font-bold text-content uppercase">{exercise.name}</h2>
|
||||||
|
<p className="text-content-muted mt-1">
|
||||||
|
Set {state.setIdx + 1} of {exercise.sets} · {exercise.weight_kg} kg · {exercise.reps > 0 ? `${exercise.reps} reps` : `${exercise.duration_seconds}s`}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-primary italic mt-2">"{exercise.coaching_tip}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer row: last set card | current set circle | total */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 gap-3">
|
||||||
|
{/* Last set card */}
|
||||||
|
<div className="flex-1 flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-emerald-400">Last Set</span>
|
||||||
|
<span className="text-xl font-mono font-bold tabular-nums text-content">
|
||||||
|
{state.lastSetStats !== null
|
||||||
|
? <ElapsedTimer seconds={state.lastSetStats.duration} />
|
||||||
|
: <span className="text-content-muted text-base">—</span>}
|
||||||
|
</span>
|
||||||
|
{state.lastSetStats !== null && (
|
||||||
|
<div className="bg-surface border border-border rounded-xl px-2.5 py-2 flex flex-col gap-1">
|
||||||
|
{state.lastSetStats.restTaken !== null && (
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-content-muted">Rest</span>
|
||||||
|
<span className="text-content font-medium tabular-nums"><ElapsedTimer seconds={state.lastSetStats.restTaken} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-content-muted">Reps</span>
|
||||||
|
<span className="text-content font-medium">{state.lastSetStats.reps}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-content-muted">Weight</span>
|
||||||
|
<span className="text-content font-medium">{state.lastSetStats.weightKg} kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-content-muted">Effort</span>
|
||||||
|
<span className="text-content font-medium">{state.lastSetStats.effort}/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElapsedTimerCircle seconds={state.setElapsed} />
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="flex-1 flex flex-col items-end gap-1.5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-amber-400">Total</span>
|
||||||
|
<span className="text-xl font-mono font-bold tabular-nums text-content">
|
||||||
|
<ElapsedTimer seconds={state.elapsed} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Set logger */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<SetLogger
|
||||||
|
exercise={exercise}
|
||||||
|
reps={state.reps}
|
||||||
|
weightKg={state.weightKg}
|
||||||
|
effort={state.effort}
|
||||||
|
onRepsChange={value => dispatch({ type: 'SET_REPS', value })}
|
||||||
|
onWeightChange={value => dispatch({ type: 'SET_WEIGHT', value })}
|
||||||
|
onEffortChange={value => dispatch({ type: 'SET_EFFORT', value })}
|
||||||
|
onComplete={handleCompleteSet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel session */}
|
||||||
|
<div className="px-6 pb-6 flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAbandonModal(true)}
|
||||||
|
className="text-sm text-content-muted hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === 'resting') {
|
||||||
|
const exercises = getExercises(state.session);
|
||||||
|
const exercise = exercises[state.exerciseIdx];
|
||||||
|
const isLastSet = state.nextSetIdx >= exercise.sets;
|
||||||
|
const nextExercise = isLastSet ? exercises[state.exerciseIdx + 1] : null;
|
||||||
|
const nextLabel = isLastSet && nextExercise
|
||||||
|
? `Next: ${nextExercise.name} · Set 1/${nextExercise.sets} · ${nextExercise.weight_kg}kg`
|
||||||
|
: `Next: Set ${state.nextSetIdx + 1}/${exercise.sets} · ${exercise.name} · ${exercise.weight_kg}kg`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base flex flex-col items-center justify-center gap-8 px-6">
|
||||||
|
{showAbandonModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Cancel Session"
|
||||||
|
message="Cancel this session? Progress logged so far will be saved but the session will be marked as abandoned."
|
||||||
|
confirmLabel="Cancel Session"
|
||||||
|
destructive
|
||||||
|
onConfirm={handleAbandon}
|
||||||
|
onCancel={() => setShowAbandonModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between w-full max-w-sm">
|
||||||
|
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div className="text-lg font-mono font-bold text-content">
|
||||||
|
<ElapsedTimer seconds={state.elapsed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl font-bold text-content">REST</h2>
|
||||||
|
<RestCountdown remaining={state.restRemaining} total={state.totalRest} />
|
||||||
|
<p className="text-content-muted text-sm text-center">{nextLabel}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch({ type: 'SKIP_REST' })}
|
||||||
|
className="px-8 py-3 rounded-xl border border-border text-content hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
SKIP REST
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAbandonModal(true)}
|
||||||
|
className="text-sm text-content-muted hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === 'complete') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base flex flex-col">
|
||||||
|
<div className="flex items-center gap-4 px-4 py-4 border-b border-border">
|
||||||
|
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content">
|
||||||
|
← Back to Kettlebell
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SessionSummary totalElapsed={state.totalElapsed} logged={state.logged} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
251
frontend/src/pages/Calendar.tsx
Normal file
251
frontend/src/pages/Calendar.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { DayPicker } from 'react-day-picker';
|
||||||
|
import 'react-day-picker/style.css';
|
||||||
|
import { CalendarDays, Dumbbell, Pill, FileText, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { DayMeta, DayDetail } from '../types/calendar';
|
||||||
|
import { getMonthSummary, getDayDetail } from '../api/calendar';
|
||||||
|
import DayModal from '../components/calendar/DayModal';
|
||||||
|
|
||||||
|
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||||
|
function toDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthStats({ meta }: { meta: DayMeta[] }) {
|
||||||
|
const workoutDays = meta.filter(d => d.has_workout).length;
|
||||||
|
const noteDays = meta.filter(d => d.has_note).length;
|
||||||
|
const daysWithSupp = meta.filter(d => d.supplement_compliance !== null);
|
||||||
|
const avgCompliance = daysWithSupp.length > 0
|
||||||
|
? Math.round(daysWithSupp.reduce((a, d) => a + (d.supplement_compliance ?? 0), 0) / daysWithSupp.length * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ icon: <Dumbbell size={16} />, label: 'Workout Days', value: workoutDays, color: 'text-green-600 dark:text-green-400' },
|
||||||
|
{ icon: <Pill size={16} />, label: 'Avg Compliance', value: avgCompliance !== null ? `${avgCompliance}%` : '—', color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
{ icon: <FileText size={16} />, label: 'Days with Notes', value: noteDays, color: 'text-yellow-600 dark:text-yellow-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} className="bg-surface rounded-xl border border-border p-3 flex flex-col items-center gap-1">
|
||||||
|
<span className={s.color}>{s.icon}</span>
|
||||||
|
<span className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{s.value}</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400 text-center leading-tight">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Calendar() {
|
||||||
|
const [month, setMonth] = useState(() => new Date());
|
||||||
|
const [monthMeta, setMonthMeta] = useState<DayMeta[]>([]);
|
||||||
|
const [metaLoading, setMetaLoading] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const [dayDetail, setDayDetail] = useState<DayDetail | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const metaByDate = Object.fromEntries(monthMeta.map(m => [m.date, m]));
|
||||||
|
|
||||||
|
const fetchMonth = useCallback(async (m: Date) => {
|
||||||
|
setMetaLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getMonthSummary(m.getFullYear(), m.getMonth() + 1);
|
||||||
|
setMonthMeta(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load calendar data');
|
||||||
|
} finally {
|
||||||
|
setMetaLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMonth(month);
|
||||||
|
}, [month, fetchMonth]);
|
||||||
|
|
||||||
|
const openDay = async (date: string) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setDayDetail(null);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getDayDetail(date);
|
||||||
|
setDayDetail(data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load day details');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDayClick = (day: Date) => {
|
||||||
|
openDay(toDateStr(day));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setSelectedDate(null);
|
||||||
|
setDayDetail(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (selectedDate) openDay(selectedDate);
|
||||||
|
fetchMonth(month);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build modifiers from monthMeta
|
||||||
|
const workoutDates = monthMeta.filter(d => d.has_workout).map(d => new Date(d.date + 'T00:00:00'));
|
||||||
|
const suppGoodDates = monthMeta
|
||||||
|
.filter(d => d.supplement_compliance !== null && d.supplement_compliance >= 0.8)
|
||||||
|
.map(d => new Date(d.date + 'T00:00:00'));
|
||||||
|
const noteDates = monthMeta.filter(d => d.has_note).map(d => new Date(d.date + 'T00:00:00'));
|
||||||
|
const calorieDates = monthMeta.filter(d => d.calorie_total && d.calorie_total > 0).map(d => new Date(d.date + 'T00:00:00'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||||
|
<CalendarDays size={24} className="text-primary" /> Calendar
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">Track your health journey day by day.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MonthStats meta={monthMeta} />
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-2xl border border-border shadow-sm p-4 sm:p-6">
|
||||||
|
{metaLoading && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DayPicker
|
||||||
|
mode="single"
|
||||||
|
month={month}
|
||||||
|
onMonthChange={setMonth}
|
||||||
|
onDayClick={handleDayClick}
|
||||||
|
modifiers={{
|
||||||
|
hasWorkout: workoutDates,
|
||||||
|
suppGood: suppGoodDates,
|
||||||
|
hasNote: noteDates,
|
||||||
|
hasCalories: calorieDates,
|
||||||
|
}}
|
||||||
|
modifiersClassNames={{
|
||||||
|
hasWorkout: 'rdp-cal-workout',
|
||||||
|
suppGood: 'rdp-cal-supp',
|
||||||
|
hasNote: 'rdp-cal-note',
|
||||||
|
hasCalories: 'rdp-cal-calories',
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: 'rdp-cal-root w-full',
|
||||||
|
months: 'w-full',
|
||||||
|
month: 'w-full',
|
||||||
|
month_grid: 'w-full',
|
||||||
|
month_caption: 'text-zinc-900 dark:text-zinc-100 font-semibold text-xl mb-4',
|
||||||
|
nav: 'text-zinc-500 dark:text-zinc-400',
|
||||||
|
day: 'rdp-cal-day text-zinc-900 dark:text-zinc-100 hover:bg-primary/10 rounded-xl transition-colors cursor-pointer relative',
|
||||||
|
selected: '!bg-primary !text-white rounded-xl',
|
||||||
|
today: 'font-bold text-primary',
|
||||||
|
outside: 'opacity-30',
|
||||||
|
weekday: 'text-zinc-500 dark:text-zinc-400 text-sm font-medium',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4 mt-4 pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-green-500 inline-block" />
|
||||||
|
Workout
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />
|
||||||
|
Supplements ≥80%
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400 inline-block" />
|
||||||
|
Note
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
/* Make the grid fill the full container width */
|
||||||
|
.rdp-cal-root,
|
||||||
|
.rdp-cal-root .rdp-months,
|
||||||
|
.rdp-cal-root .rdp-month,
|
||||||
|
.rdp-cal-root .rdp-month_grid {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table layout: fixed forces equal column distribution */
|
||||||
|
.rdp-cal-root .rdp-month_grid {
|
||||||
|
table-layout: fixed !important;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Equal-width columns */
|
||||||
|
.rdp-cal-root .rdp-weekday,
|
||||||
|
.rdp-cal-root .rdp-day {
|
||||||
|
width: calc(100% / 7);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day button fills the cell, square aspect ratio */
|
||||||
|
.rdp-cal-root .rdp-day_button {
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weekday header font */
|
||||||
|
.rdp-cal-root .rdp-weekday {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicator dots sit on the button itself */
|
||||||
|
.rdp-cal-workout .rdp-day_button,
|
||||||
|
.rdp-cal-supp .rdp-day_button,
|
||||||
|
.rdp-cal-note .rdp-day_button {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.rdp-cal-workout .rdp-day_button::after,
|
||||||
|
.rdp-cal-supp .rdp-day_button::after,
|
||||||
|
.rdp-cal-note .rdp-day_button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.rdp-cal-workout .rdp-day_button::after { background: #22c55e; }
|
||||||
|
.rdp-cal-supp .rdp-day_button::after { background: #3b82f6; left: calc(50% + 8px); }
|
||||||
|
.rdp-cal-note .rdp-day_button::after { background: #facc15; left: calc(50% - 8px); }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<DayModal
|
||||||
|
date={selectedDate}
|
||||||
|
detail={dayDetail}
|
||||||
|
loading={detailLoading}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
BarChart, Bar, PieChart, Pie, Cell, Legend
|
PieChart, Pie, Cell, Legend
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Activity, Flame, Footprints, Scale, Utensils } from 'lucide-react';
|
import { Activity, Flame, Footprints, Scale, Utensils } from 'lucide-react';
|
||||||
|
import type { FoodLog } from '../types/nutrition';
|
||||||
|
import type { HealthMetric } from '../types/health';
|
||||||
|
import type { NutritionSummary } from '../types/nutrition';
|
||||||
|
import { getNutritionSummary } from '../api/nutrition';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [metrics, setMetrics] = useState([]);
|
const [metrics, setMetrics] = useState<HealthMetric[]>([]);
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState<FoodLog[]>([]);
|
||||||
|
const [summary, setSummary] = useState<NutritionSummary | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [metricsRes, logsRes] = await Promise.all([
|
const [metricsRes, logsRes, summaryData] = await Promise.all([
|
||||||
client.get('/health/metrics'),
|
client.get('/health/metrics'),
|
||||||
client.get('/nutrition/logs')
|
client.get('/nutrition/logs'),
|
||||||
|
getNutritionSummary(),
|
||||||
]);
|
]);
|
||||||
setMetrics(metricsRes.data);
|
setMetrics(metricsRes.data);
|
||||||
setLogs(logsRes.data);
|
setLogs(logsRes.data);
|
||||||
|
setSummary(summaryData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load dashboard data", error);
|
console.error("Failed to load dashboard data", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,7 +178,7 @@ const Dashboard = () => {
|
|||||||
paddingAngle={5}
|
paddingAngle={5}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
{activeMacroData.map((entry, index) => (
|
{activeMacroData.map((_entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -191,6 +198,41 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Macro Targets Progress */}
|
||||||
|
{summary && (summary.target_calories || summary.target_protein || summary.target_carbs || summary.target_fat) && (
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-content mb-6 flex items-center gap-2">
|
||||||
|
<Flame size={18} className="text-primary" />
|
||||||
|
Today's Macro Targets
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[
|
||||||
|
{ label: 'Calories', current: Math.round(summary.total_calories), target: summary.target_calories, unit: 'kcal', color: 'bg-orange-500' },
|
||||||
|
{ label: 'Protein', current: Math.round(summary.total_protein), target: summary.target_protein, unit: 'g', color: 'bg-red-500' },
|
||||||
|
{ label: 'Carbs', current: Math.round(summary.total_carbs), target: summary.target_carbs, unit: 'g', color: 'bg-amber-500' },
|
||||||
|
{ label: 'Fat', current: Math.round(summary.total_fats), target: summary.target_fat, unit: 'g', color: 'bg-blue-500' },
|
||||||
|
].filter(m => m.target).map(macro => {
|
||||||
|
const pct = Math.min(100, Math.round(((macro.current) / (macro.target!)) * 100));
|
||||||
|
return (
|
||||||
|
<div key={macro.label}>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="font-medium text-content">{macro.label}</span>
|
||||||
|
<span className="text-content-muted">{macro.current} / {macro.target} {macro.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-base rounded-full h-2.5 overflow-hidden border border-border">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${macro.color} transition-all duration-500`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-muted mt-1">{pct}%</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
<h3 className="text-lg font-semibold text-content mb-4">Recent Food Logs</h3>
|
<h3 className="text-lg font-semibold text-content mb-4">Recent Food Logs</h3>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useMemo, FormEvent, ChangeEvent } from 'react';
|
import { useState, useEffect, useMemo, FormEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
import { Input } from '../components/catalyst/input';
|
import { Input } from '../components/catalyst/input';
|
||||||
import { Select } from '../components/catalyst/select';
|
import { Select } from '../components/catalyst/select';
|
||||||
import { Button } from '../components/catalyst/button';
|
import { Button } from '../components/catalyst/button';
|
||||||
@@ -56,7 +57,7 @@ const Health = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to add metric');
|
toast.error('Failed to add metric');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@ const Health = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to add goal');
|
toast.error('Failed to add goal');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
270
frontend/src/pages/Kettlebell.tsx
Normal file
270
frontend/src/pages/Kettlebell.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { generateSession, getSessions, deleteSession, retrySession } from '../api/kettlebell';
|
||||||
|
import type { KettlebellSession } from '../types/kettlebell';
|
||||||
|
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
import { BarChart2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const FOCUS_OPTIONS = ['strength', 'conditioning', 'fat loss', 'mobility', 'endurance'];
|
||||||
|
const DURATION_OPTIONS = [20, 30, 45, 60];
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
generated: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
completed: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
||||||
|
abandoned: 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Kettlebell() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sessions, setSessions] = useState<KettlebellSession[]>([]);
|
||||||
|
const [selected, setSelected] = useState<KettlebellSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<KettlebellSession | null>(null);
|
||||||
|
|
||||||
|
const [focus, setFocus] = useState('strength');
|
||||||
|
const [duration, setDuration] = useState(30);
|
||||||
|
const [weightsInput, setWeightsInput] = useState('16, 24, 32');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSessions().then(data => {
|
||||||
|
setSessions(data);
|
||||||
|
if (data.length > 0) setSelected(data[0]);
|
||||||
|
}).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const newSession = await retrySession(id);
|
||||||
|
setSessions(prev => [newSession, ...prev]);
|
||||||
|
setSelected(newSession);
|
||||||
|
navigate(`/kettlebell/session/${newSession.id}`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to create retry session.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const id = deleteTarget.id;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
try {
|
||||||
|
await deleteSession(id);
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== id));
|
||||||
|
if (selected?.id === id) setSelected(null);
|
||||||
|
} catch {
|
||||||
|
// silently ignore — could add toast here in future
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const available_weights = weightsInput
|
||||||
|
.split(',')
|
||||||
|
.map(s => parseFloat(s.trim()))
|
||||||
|
.filter(n => !isNaN(n) && n > 0);
|
||||||
|
const session = await generateSession({ focus, duration_minutes: duration, available_weights });
|
||||||
|
setSessions(prev => [session, ...prev]);
|
||||||
|
setSelected(session);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to generate session');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exercises = selected?.exercises?.exercises ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{deleteTarget && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete Session"
|
||||||
|
message={`Delete "${deleteTarget.title}"? This cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Heading>Kettlebell</Heading>
|
||||||
|
<Button outline onClick={() => navigate('/kettlebell/analytics')}>
|
||||||
|
<BarChart2 size={16} /> Analytics
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-6 lg:col-span-1">
|
||||||
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
|
<Subheading className="mb-6 text-primary">Generate Session</Subheading>
|
||||||
|
<form onSubmit={handleGenerate} className="space-y-4">
|
||||||
|
<Field>
|
||||||
|
<Label>Focus</Label>
|
||||||
|
<select
|
||||||
|
value={focus}
|
||||||
|
onChange={e => setFocus(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-border bg-base text-content px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{FOCUS_OPTIONS.map(f => (
|
||||||
|
<option key={f} value={f}>{f.charAt(0).toUpperCase() + f.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Duration (minutes)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{DURATION_OPTIONS.map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDuration(d)}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-medium border transition-colors ${duration === d
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-base border-border text-content hover:bg-base/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}m
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Available Weights (kg, comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 16, 24, 32"
|
||||||
|
value={weightsInput}
|
||||||
|
onChange={e => setWeightsInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" color="dark/zinc" disabled={loading} className="w-full">
|
||||||
|
{loading ? 'Generating...' : 'Generate Session'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border h-96 overflow-y-auto">
|
||||||
|
<Subheading className="mb-4">History</Subheading>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setSelected(s)}
|
||||||
|
className={`p-4 rounded-xl cursor-pointer transition-colors border-l-4 ${selected?.id === s.id
|
||||||
|
? 'bg-base border-primary'
|
||||||
|
: 'bg-base/50 border-border hover:bg-base'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-bold text-content truncate">{s.title}</p>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[s.status] || statusColors.generated}`}>
|
||||||
|
{s.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setDeleteTarget(s); }}
|
||||||
|
className="text-content-muted hover:text-red-500 transition-colors text-xs leading-none"
|
||||||
|
title="Delete session"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-muted mt-1">
|
||||||
|
{s.focus} · {s.total_duration_min}min · {new Date(s.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && <p className="text-content-muted">No sessions yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{selected ? (
|
||||||
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border min-h-[600px] flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-6 border-b border-border pb-4">
|
||||||
|
<div>
|
||||||
|
<Heading className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-orange-400">
|
||||||
|
{selected.title}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-content-muted mt-1 capitalize">
|
||||||
|
{selected.focus} · {selected.difficulty} · {selected.total_duration_min} min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-3 py-1 rounded-full border border-border ${statusColors[selected.status] || statusColors.generated}`}>
|
||||||
|
{selected.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{exercises.map((ex, i) => (
|
||||||
|
<div key={i} className="p-4 bg-base rounded-xl border border-border">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-content">{ex.order}. {ex.name}</p>
|
||||||
|
<p className="text-sm text-content-muted mt-0.5">{ex.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-content-muted shrink-0 ml-4">
|
||||||
|
<p>{ex.sets} × {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}</p>
|
||||||
|
<p>{ex.weight_kg} kg</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-primary mt-2 italic">"{ex.coaching_tip}"</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.notes && (
|
||||||
|
<div className="mt-4 p-4 bg-base/50 rounded-xl border border-border">
|
||||||
|
<p className="text-sm text-content-muted">{selected.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selected.status === 'generated' || selected.status === 'in_progress') && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
color="dark/zinc"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate(`/kettlebell/session/${selected.id}`)}
|
||||||
|
>
|
||||||
|
{selected.status === 'in_progress' ? 'Resume Workout' : 'Start Workout'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.status === 'abandoned' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
color="dark/zinc"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleRetry(selected.id)}
|
||||||
|
>
|
||||||
|
Retry Session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="text-6xl mb-4">🏋️</div>
|
||||||
|
<Heading level={2} className="mb-2">Ready to Train?</Heading>
|
||||||
|
<p className="text-content-muted max-w-md">
|
||||||
|
Generate an AI-powered kettlebell session tailored to your profile and goals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
frontend/src/pages/KettlebellAnalytics.tsx
Normal file
242
frontend/src/pages/KettlebellAnalytics.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, LineChart, Line, AreaChart, Area,
|
||||||
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, Trophy, Zap, BarChart2, ChevronLeft } from 'lucide-react';
|
||||||
|
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||||
|
import { Select } from '../components/catalyst/select';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import client from '../api/client';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ExerciseProgression {
|
||||||
|
date: string;
|
||||||
|
max_weight: number;
|
||||||
|
avg_rpe: number;
|
||||||
|
total_volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalRecord {
|
||||||
|
exercise_name: string;
|
||||||
|
max_weight: number;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyVolume {
|
||||||
|
week: string;
|
||||||
|
sessions: number;
|
||||||
|
total_volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsData {
|
||||||
|
weekly_sessions: WeeklyVolume[];
|
||||||
|
exercise_progressions: Record<string, ExerciseProgression[]>;
|
||||||
|
personal_records: PersonalRecord[];
|
||||||
|
avg_rpe_trend: { date: string; avg_rpe: number; session_title: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KettlebellAnalytics = () => {
|
||||||
|
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedExercise, setSelectedExercise] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/kettlebell/analytics');
|
||||||
|
setData(res.data);
|
||||||
|
const exercises = Object.keys(res.data.exercise_progressions);
|
||||||
|
if (exercises.length > 0) setSelectedExercise(exercises[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to load analytics');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAnalytics();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8 text-center text-content-muted">Loading analytics...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const hasData = data.weekly_sessions.length > 0;
|
||||||
|
const exerciseNames = Object.keys(data.exercise_progressions);
|
||||||
|
const progressionData = selectedExercise ? data.exercise_progressions[selectedExercise] ?? [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button outline onClick={() => navigate('/kettlebell')}>
|
||||||
|
<ChevronLeft size={16} /> Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Heading>Workout Analytics</Heading>
|
||||||
|
<p className="text-content-muted">Performance insights from your completed sessions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="text-center py-24 text-content-muted">
|
||||||
|
<BarChart2 size={64} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-xl font-semibold">No completed sessions yet</p>
|
||||||
|
<p className="text-sm mt-2">Complete some kettlebell sessions to see your analytics here.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||||
|
<p className="text-2xl font-bold text-content">{data.weekly_sessions.reduce((a, w) => a + w.sessions, 0)}</p>
|
||||||
|
<p className="text-sm text-content-muted mt-1">Total Sessions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||||
|
<p className="text-2xl font-bold text-content">{data.personal_records.length}</p>
|
||||||
|
<p className="text-sm text-content-muted mt-1">Exercises Tracked</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||||
|
<p className="text-2xl font-bold text-content">
|
||||||
|
{data.avg_rpe_trend.length > 0
|
||||||
|
? (data.avg_rpe_trend.reduce((a, r) => a + r.avg_rpe, 0) / data.avg_rpe_trend.length).toFixed(1)
|
||||||
|
: '--'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-content-muted mt-1">Avg RPE</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||||
|
<p className="text-2xl font-bold text-content">
|
||||||
|
{Math.round(data.weekly_sessions.reduce((a, w) => a + w.total_volume, 0))} kg
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-content-muted mt-1">Total Volume</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Sessions per Week */}
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<Subheading className="mb-6 flex items-center gap-2">
|
||||||
|
<BarChart2 size={18} className="text-primary" /> Sessions Per Week
|
||||||
|
</Subheading>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data.weekly_sessions}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||||
|
<XAxis dataKey="week" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="sessions" fill="var(--color-primary)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RPE Trend */}
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<Subheading className="mb-6 flex items-center gap-2">
|
||||||
|
<Zap size={18} className="text-primary" /> Avg RPE Trend
|
||||||
|
</Subheading>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data.avg_rpe_trend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||||
|
<YAxis domain={[1, 10]} axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="avg_rpe" stroke="#F59E0B" strokeWidth={3} dot={{ r: 4, fill: '#F59E0B' }} activeDot={{ r: 6 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exercise Weight Progression */}
|
||||||
|
{exerciseNames.length > 0 && (
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Subheading className="flex items-center gap-2">
|
||||||
|
<TrendingUp size={18} className="text-primary" /> Weight Progression
|
||||||
|
</Subheading>
|
||||||
|
<Select
|
||||||
|
value={selectedExercise}
|
||||||
|
onChange={e => setSelectedExercise(e.target.value)}
|
||||||
|
className="text-sm max-w-xs"
|
||||||
|
>
|
||||||
|
{exerciseNames.map(name => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={progressionData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorWeight" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Area type="monotone" dataKey="max_weight" name="Max Weight (kg)" stroke="var(--color-primary)" strokeWidth={3} fillOpacity={1} fill="url(#colorWeight)" />
|
||||||
|
<Area type="monotone" dataKey="total_volume" name="Volume (kg)" stroke="#10B981" strokeWidth={2} fill="none" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Personal Records Table */}
|
||||||
|
{data.personal_records.length > 0 && (
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<Subheading className="mb-6 flex items-center gap-2">
|
||||||
|
<Trophy size={18} className="text-primary" /> Personal Records
|
||||||
|
</Subheading>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-3 px-4 text-content-muted font-medium">Exercise</th>
|
||||||
|
<th className="text-right py-3 px-4 text-content-muted font-medium">Max Weight</th>
|
||||||
|
<th className="text-right py-3 px-4 text-content-muted font-medium">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.personal_records.map((pr, i) => (
|
||||||
|
<tr key={pr.exercise_name} className={`border-b border-border/50 hover:bg-base/50 transition-colors ${i === 0 ? 'text-primary font-semibold' : ''}`}>
|
||||||
|
<td className="py-3 px-4 text-content">
|
||||||
|
{i === 0 && <Trophy size={14} className="inline mr-2 text-yellow-500" />}
|
||||||
|
{pr.exercise_name}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right font-mono">{pr.max_weight} kg</td>
|
||||||
|
<td className="py-3 px-4 text-right text-content-muted">{pr.date}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KettlebellAnalytics;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, FormEvent, ChangeEvent } from 'react';
|
import { useState, useRef, ChangeEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Utensils
|
Utensils
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Field, Label } from '../components/catalyst/fieldset';
|
import { Field } from '../components/catalyst/fieldset';
|
||||||
import { Textarea } from '../components/catalyst/textarea';
|
import { Textarea } from '../components/catalyst/textarea';
|
||||||
import { Button } from '../components/catalyst/button';
|
import { Button } from '../components/catalyst/button';
|
||||||
import { Heading } from '../components/catalyst/heading';
|
import { Heading } from '../components/catalyst/heading';
|
||||||
@@ -78,7 +79,7 @@ const Nutrition = () => {
|
|||||||
setShowReasoning(false);
|
setShowReasoning(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to analyze. Please try again.');
|
toast.error('Failed to analyze. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,10 +92,10 @@ const Nutrition = () => {
|
|||||||
setAnalysis(null);
|
setAnalysis(null);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
clearFile();
|
clearFile();
|
||||||
alert('Meal logged successfully!');
|
toast.success('Meal logged successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to save log.');
|
toast.error('Failed to save log.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, FormEvent } from 'react';
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
import { Field, Label } from '../components/catalyst/fieldset';
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
import { Input } from '../components/catalyst/input';
|
import { Input } from '../components/catalyst/input';
|
||||||
@@ -52,7 +53,7 @@ const Plans = () => {
|
|||||||
setUserDetails('');
|
setUserDetails('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to generate plan');
|
toast.error('Failed to generate plan');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect, useContext, FormEvent, ChangeEvent } from 'react';
|
import { useState, useEffect, useContext, FormEvent, ChangeEvent } from 'react';
|
||||||
import { AuthContext } from '../context/AuthContext';
|
import { AuthContext } from '../context/AuthContext';
|
||||||
import { User, Ruler, Weight, Activity, Save, AlertCircle } from 'lucide-react';
|
import { toast } from 'sonner';
|
||||||
|
import { User, Ruler, Weight, Activity, Save, Target, Bell } from 'lucide-react';
|
||||||
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
||||||
import { Input } from '../components/catalyst/input';
|
import { Input } from '../components/catalyst/input';
|
||||||
import { Select } from '../components/catalyst/select';
|
import { Select } from '../components/catalyst/select';
|
||||||
import { Button } from '../components/catalyst/button';
|
import { Button } from '../components/catalyst/button';
|
||||||
import { Heading } from '../components/catalyst/heading';
|
import { Heading } from '../components/catalyst/heading';
|
||||||
|
import { getVapidPublicKey, subscribePush, unsubscribePush, sendTestNotification, urlBase64ToUint8Array } from '../api/push';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
firstname: string;
|
firstname: string;
|
||||||
@@ -22,9 +24,21 @@ interface FormData {
|
|||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { user, updateUser } = useContext(AuthContext);
|
const { user, updateUser } = useContext(AuthContext);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState({ type: '', text: '' });
|
const [targets, setTargets] = useState({
|
||||||
|
target_calories: '',
|
||||||
|
target_protein: '',
|
||||||
|
target_carbs: '',
|
||||||
|
target_fat: '',
|
||||||
|
});
|
||||||
|
|
||||||
const [unitSystem, setUnitSystem] = useState('metric'); // 'metric' or 'imperial'
|
const [unitSystem, setUnitSystem] = useState('metric'); // 'metric' or 'imperial'
|
||||||
|
|
||||||
|
// Push notification state
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||||
|
const [notifLoading, setNotifLoading] = useState(false);
|
||||||
|
const [reminderHour, setReminderHour] = useState(9);
|
||||||
|
const [reminderMinute, setReminderMinute] = useState(0);
|
||||||
|
const [currentSub, setCurrentSub] = useState<PushSubscription | null>(null);
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
firstname: '',
|
firstname: '',
|
||||||
lastname: '',
|
lastname: '',
|
||||||
@@ -41,6 +55,12 @@ const Profile = () => {
|
|||||||
if (user) {
|
if (user) {
|
||||||
const prefs = user.unit_preference || 'metric';
|
const prefs = user.unit_preference || 'metric';
|
||||||
setUnitSystem(prefs);
|
setUnitSystem(prefs);
|
||||||
|
setTargets({
|
||||||
|
target_calories: user.target_calories?.toString() || '',
|
||||||
|
target_protein: user.target_protein?.toString() || '',
|
||||||
|
target_carbs: user.target_carbs?.toString() || '',
|
||||||
|
target_fat: user.target_fat?.toString() || '',
|
||||||
|
});
|
||||||
|
|
||||||
const h_cm = user.height || '';
|
const h_cm = user.height || '';
|
||||||
const w_kg = user.weight || '';
|
const w_kg = user.weight || '';
|
||||||
@@ -70,6 +90,86 @@ const Profile = () => {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((reg) => reg.pushManager.getSubscription())
|
||||||
|
.then((sub) => {
|
||||||
|
if (sub) {
|
||||||
|
setCurrentSub(sub);
|
||||||
|
setNotifEnabled(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnableNotifications = async () => {
|
||||||
|
setNotifLoading(true);
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
toast.error('Notification permission denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const publicKey = await getVapidPublicKey();
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
const json = sub.toJSON();
|
||||||
|
await subscribePush({
|
||||||
|
endpoint: json.endpoint!,
|
||||||
|
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||||
|
reminder_hour: reminderHour,
|
||||||
|
reminder_minute: reminderMinute,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
});
|
||||||
|
setCurrentSub(sub);
|
||||||
|
setNotifEnabled(true);
|
||||||
|
toast.success('Notifications enabled!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Push subscribe error:', err);
|
||||||
|
toast.error('Failed to enable notifications');
|
||||||
|
} finally {
|
||||||
|
setNotifLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableNotifications = async () => {
|
||||||
|
setNotifLoading(true);
|
||||||
|
try {
|
||||||
|
if (currentSub) {
|
||||||
|
await unsubscribePush(currentSub.endpoint);
|
||||||
|
await currentSub.unsubscribe();
|
||||||
|
}
|
||||||
|
setCurrentSub(null);
|
||||||
|
setNotifEnabled(false);
|
||||||
|
toast.success('Notifications disabled');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to disable notifications');
|
||||||
|
} finally {
|
||||||
|
setNotifLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateReminderTime = async () => {
|
||||||
|
if (!currentSub) return;
|
||||||
|
try {
|
||||||
|
const json = currentSub.toJSON();
|
||||||
|
await subscribePush({
|
||||||
|
endpoint: json.endpoint!,
|
||||||
|
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||||
|
reminder_hour: reminderHour,
|
||||||
|
reminder_minute: reminderMinute,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
});
|
||||||
|
toast.success('Reminder time updated');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update reminder time');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUnitChange = (system: string) => {
|
const handleUnitChange = (system: string) => {
|
||||||
setUnitSystem(system);
|
setUnitSystem(system);
|
||||||
};
|
};
|
||||||
@@ -108,9 +208,8 @@ const Profile = () => {
|
|||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage({ type: '', text: '' });
|
|
||||||
|
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
firstname: formData.firstname,
|
firstname: formData.firstname,
|
||||||
lastname: formData.lastname,
|
lastname: formData.lastname,
|
||||||
age: parseInt(formData.age),
|
age: parseInt(formData.age),
|
||||||
@@ -119,12 +218,16 @@ const Profile = () => {
|
|||||||
height: parseFloat(String(formData.height_cm)),
|
height: parseFloat(String(formData.height_cm)),
|
||||||
weight: parseFloat(String(formData.weight_kg)),
|
weight: parseFloat(String(formData.weight_kg)),
|
||||||
};
|
};
|
||||||
|
if (targets.target_calories) payload.target_calories = parseFloat(targets.target_calories);
|
||||||
|
if (targets.target_protein) payload.target_protein = parseFloat(targets.target_protein);
|
||||||
|
if (targets.target_carbs) payload.target_carbs = parseFloat(targets.target_carbs);
|
||||||
|
if (targets.target_fat) payload.target_fat = parseFloat(targets.target_fat);
|
||||||
|
|
||||||
const success = await updateUser(payload);
|
const success = await updateUser(payload);
|
||||||
if (success) {
|
if (success) {
|
||||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
toast.success('Profile updated successfully!');
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: 'error', text: 'Failed to update profile.' });
|
toast.error('Failed to update profile.');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -137,14 +240,6 @@ const Profile = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border">
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border">
|
||||||
{message.text && (
|
|
||||||
<div className={`p-4 mb-6 rounded-lg flex items-center gap-3 ${message.type === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-100 text-red-800'
|
|
||||||
}`}>
|
|
||||||
<AlertCircle size={20} />
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<Fieldset>
|
<Fieldset>
|
||||||
@@ -298,6 +393,122 @@ const Profile = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
|
<div className="border-t border-border my-6"></div>
|
||||||
|
|
||||||
|
{/* Nutrition Targets */}
|
||||||
|
<Fieldset>
|
||||||
|
<Legend className="flex items-center gap-2">
|
||||||
|
<Target className="text-primary" size={24} />
|
||||||
|
Daily Nutrition Targets
|
||||||
|
</Legend>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mt-6">
|
||||||
|
<Field>
|
||||||
|
<Label>Calories (kcal)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={targets.target_calories}
|
||||||
|
onChange={(e) => setTargets(t => ({ ...t, target_calories: e.target.value }))}
|
||||||
|
placeholder="2000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Protein (g)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={targets.target_protein}
|
||||||
|
onChange={(e) => setTargets(t => ({ ...t, target_protein: e.target.value }))}
|
||||||
|
placeholder="150"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Carbs (g)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={targets.target_carbs}
|
||||||
|
onChange={(e) => setTargets(t => ({ ...t, target_carbs: e.target.value }))}
|
||||||
|
placeholder="200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Fat (g)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={targets.target_fat}
|
||||||
|
onChange={(e) => setTargets(t => ({ ...t, target_fat: e.target.value }))}
|
||||||
|
placeholder="65"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<div className="border-t border-border my-6"></div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{'serviceWorker' in navigator && 'PushManager' in window && (
|
||||||
|
<Fieldset>
|
||||||
|
<Legend className="flex items-center gap-2">
|
||||||
|
<Bell className="text-primary" size={24} />
|
||||||
|
Notifications
|
||||||
|
</Legend>
|
||||||
|
<p className="text-content-muted text-sm mt-1">
|
||||||
|
Receive daily reminders to log your supplements.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between max-w-sm">
|
||||||
|
<Label>Enable supplement reminders</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={notifLoading}
|
||||||
|
onClick={notifEnabled ? handleDisableNotifications : handleEnableNotifications}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 ${notifEnabled ? 'bg-primary' : 'bg-zinc-300 dark:bg-zinc-600'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${notifEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notifEnabled && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 max-w-xs">
|
||||||
|
<Field>
|
||||||
|
<Label>Hour (0–23)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={reminderHour}
|
||||||
|
onChange={(e) => setReminderHour(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Minute</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
value={reminderMinute}
|
||||||
|
onChange={(e) => setReminderMinute(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="button" color="dark/zinc" onClick={handleUpdateReminderTime}>
|
||||||
|
Update reminder time
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
outline
|
||||||
|
onClick={() => sendTestNotification().then(() => toast.success('Test notification sent!')).catch(() => toast.error('Failed to send test'))}
|
||||||
|
>
|
||||||
|
Send test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end">
|
<div className="pt-4 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
343
frontend/src/pages/Supplements.tsx
Normal file
343
frontend/src/pages/Supplements.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { CheckCircle2, Circle, Plus, Pill, Flame, Trash2, X } from 'lucide-react';
|
||||||
|
import { Heading } from '../components/catalyst/heading';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { Select } from '../components/catalyst/select';
|
||||||
|
import {
|
||||||
|
getTodaySupplements,
|
||||||
|
createSupplement,
|
||||||
|
deleteSupplement,
|
||||||
|
logSupplement,
|
||||||
|
} from '../api/supplements';
|
||||||
|
import type { SupplementCreate, SupplementWithStatus } from '../types/supplement';
|
||||||
|
|
||||||
|
const UNITS = ['mg', 'mcg', 'IU', 'g', 'ml', 'capsule', 'tablet'];
|
||||||
|
const FREQUENCIES = ['daily', 'weekly', 'as_needed'];
|
||||||
|
|
||||||
|
const DEFAULT_FORM: SupplementCreate = {
|
||||||
|
name: '',
|
||||||
|
dosage: 0,
|
||||||
|
unit: 'mg',
|
||||||
|
frequency: 'daily',
|
||||||
|
scheduled_times: [],
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Supplements = () => {
|
||||||
|
const [supplements, setSupplements] = useState<SupplementWithStatus[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState<SupplementCreate>(DEFAULT_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [timeInput, setTimeInput] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'today' | 'manage'>('today');
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getTodaySupplements();
|
||||||
|
setSupplements(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to load supplements');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async (s: SupplementWithStatus) => {
|
||||||
|
if (s.taken_today) return; // can't untake
|
||||||
|
try {
|
||||||
|
await logSupplement(s.id);
|
||||||
|
setSupplements(prev =>
|
||||||
|
prev.map(item => item.id === s.id ? { ...item, taken_today: true, streak: item.streak + 1 } : item)
|
||||||
|
);
|
||||||
|
toast.success(`${s.name} logged!`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to log supplement');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTime = () => {
|
||||||
|
if (!timeInput) return;
|
||||||
|
setForm(f => ({ ...f, scheduled_times: [...f.scheduled_times, timeInput] }));
|
||||||
|
setTimeInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTime = (t: string) => {
|
||||||
|
setForm(f => ({ ...f, scheduled_times: f.scheduled_times.filter(x => x !== t) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name || form.dosage <= 0) {
|
||||||
|
toast.error('Please fill in name and dosage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createSupplement(form);
|
||||||
|
toast.success(`${form.name} added!`);
|
||||||
|
setForm(DEFAULT_FORM);
|
||||||
|
setShowForm(false);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to add supplement');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, name: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSupplement(id);
|
||||||
|
toast.success(`${name} removed`);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to remove supplement');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const taken = supplements.filter(s => s.taken_today).length;
|
||||||
|
const total = supplements.length;
|
||||||
|
const progress = total > 0 ? Math.round((taken / total) * 100) : 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8 text-center text-content-muted">Loading supplements...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Heading>Supplements</Heading>
|
||||||
|
<p className="text-content-muted">Track your daily supplement intake.</p>
|
||||||
|
</div>
|
||||||
|
<Button color="dark/zinc" onClick={() => setShowForm(!showForm)}>
|
||||||
|
<Plus size={16} /> Add Supplement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Supplement Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<Heading>New Supplement</Heading>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-content-muted hover:text-content">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="Vitamin D3"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field>
|
||||||
|
<Label>Dosage</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.dosage || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, dosage: parseFloat(e.target.value) || 0 }))}
|
||||||
|
placeholder="5000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Unit</Label>
|
||||||
|
<Select value={form.unit} onChange={e => setForm(f => ({ ...f, unit: e.target.value }))}>
|
||||||
|
{UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<Label>Frequency</Label>
|
||||||
|
<Select value={form.frequency} onChange={e => setForm(f => ({ ...f, frequency: e.target.value }))}>
|
||||||
|
{FREQUENCIES.map(freq => (
|
||||||
|
<option key={freq} value={freq}>{freq.replace('_', ' ')}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.notes || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||||
|
placeholder="Take with food"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scheduled Times */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-content mb-1">Scheduled Times</p>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={timeInput}
|
||||||
|
onChange={e => setTimeInput(e.target.value)}
|
||||||
|
className="w-36"
|
||||||
|
/>
|
||||||
|
<Button type="button" outline onClick={handleAddTime}>Add Time</Button>
|
||||||
|
</div>
|
||||||
|
{form.scheduled_times.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{form.scheduled_times.map(t => (
|
||||||
|
<span key={t} className="flex items-center gap-1 px-3 py-1 bg-primary/10 text-primary text-sm rounded-full">
|
||||||
|
{t}
|
||||||
|
<button type="button" onClick={() => handleRemoveTime(t)} className="hover:text-red-500">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" color="dark/zinc" disabled={submitting}>
|
||||||
|
{submitting ? 'Adding...' : 'Add Supplement'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" outline onClick={() => setShowForm(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-surface p-1 rounded-xl border border-border w-fit">
|
||||||
|
{(['today', 'manage'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all capitalize ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-content-muted hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'today' ? "Today's Checklist" : 'Manage'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'today' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Daily Progress */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<span className="font-semibold text-content">Daily Progress</span>
|
||||||
|
<span className="text-sm font-medium text-content-muted">{taken} / {total} taken</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-base rounded-full h-3 overflow-hidden border border-border">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-muted mt-2">{progress}% complete</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supplement Checklist */}
|
||||||
|
{supplements.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-content-muted">
|
||||||
|
<Pill size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg font-medium">No supplements yet</p>
|
||||||
|
<p className="text-sm">Click "Add Supplement" to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{supplements.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => handleToggle(s)}
|
||||||
|
className={`flex items-center gap-4 p-4 rounded-2xl border cursor-pointer transition-all duration-200 ${
|
||||||
|
s.taken_today
|
||||||
|
? 'bg-primary/5 border-primary/30 opacity-75'
|
||||||
|
: 'bg-surface border-border hover:border-primary/50 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`flex-shrink-0 ${s.taken_today ? 'text-primary' : 'text-content-muted'}`}>
|
||||||
|
{s.taken_today ? <CheckCircle2 size={28} /> : <Circle size={28} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={`font-semibold text-content ${s.taken_today ? 'line-through opacity-60' : ''}`}>
|
||||||
|
{s.name}
|
||||||
|
</p>
|
||||||
|
{s.streak > 1 && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-orange-500 font-medium">
|
||||||
|
<Flame size={12} /> {s.streak}d
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-content-muted">
|
||||||
|
{s.dosage} {s.unit}
|
||||||
|
{s.scheduled_times?.length > 0 && ` · ${s.scheduled_times.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{s.taken_today && (
|
||||||
|
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-full">
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'manage' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{supplements.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-content-muted">
|
||||||
|
<Pill size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>No supplements added yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
supplements.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between p-4 bg-surface rounded-2xl border border-border">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-content">{s.name}</p>
|
||||||
|
<p className="text-sm text-content-muted">
|
||||||
|
{s.dosage} {s.unit} · {s.frequency}
|
||||||
|
{s.notes && ` · ${s.notes}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(s.id, s.name)}
|
||||||
|
className="p-2 text-content-muted hover:text-red-500 transition-colors rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Supplements;
|
||||||
51
frontend/src/sw.ts
Normal file
51
frontend/src/sw.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// self.__WB_MANIFEST is replaced by vite-plugin-pwa with the actual precache manifest array
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
|
self.addEventListener('push', (event: PushEvent) => {
|
||||||
|
console.log('[SW] push received', event.data?.text());
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let title = 'HealthyFit';
|
||||||
|
let body = 'You have a new notification';
|
||||||
|
let url = '/';
|
||||||
|
try {
|
||||||
|
const data = event.data.json() as { title: string; body: string; url?: string };
|
||||||
|
title = data.title;
|
||||||
|
body = data.body;
|
||||||
|
url = data.url ?? '/';
|
||||||
|
} catch {
|
||||||
|
body = event.data.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { url },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = (event.notification.data as { url: string }).url;
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clients) => {
|
||||||
|
const existing = clients.find((c) => c.url.includes(url));
|
||||||
|
if (existing) return existing.focus();
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
49
frontend/src/types/calendar.ts
Normal file
49
frontend/src/types/calendar.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SupplementWithStatus } from './supplement';
|
||||||
|
|
||||||
|
export interface DayMeta {
|
||||||
|
date: string; // "YYYY-MM-DD"
|
||||||
|
has_note: boolean;
|
||||||
|
event_count: number;
|
||||||
|
supplement_compliance: number | null; // 0.0–1.0
|
||||||
|
has_workout: boolean;
|
||||||
|
calorie_total: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyNote {
|
||||||
|
id?: number;
|
||||||
|
date: string;
|
||||||
|
content: string;
|
||||||
|
mood?: string;
|
||||||
|
energy_level?: number;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id?: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
event_type: 'workout' | 'supplement' | 'general';
|
||||||
|
color?: string;
|
||||||
|
start_time?: string;
|
||||||
|
is_completed: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KettlebellSessionSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
focus: string;
|
||||||
|
total_duration_min: number;
|
||||||
|
difficulty: string;
|
||||||
|
status: string;
|
||||||
|
completed_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayDetail {
|
||||||
|
date: string;
|
||||||
|
note: DailyNote | null;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
supplements: SupplementWithStatus[];
|
||||||
|
kettlebell_sessions: KettlebellSessionSummary[];
|
||||||
|
}
|
||||||
27
frontend/src/types/health.ts
Normal file
27
frontend/src/types/health.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface HealthMetric {
|
||||||
|
id: number;
|
||||||
|
metric_type: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthGoal {
|
||||||
|
id: number;
|
||||||
|
goal_type: string;
|
||||||
|
target_value: number;
|
||||||
|
target_date?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthMetricCreate {
|
||||||
|
metric_type: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthGoalCreate {
|
||||||
|
goal_type: string;
|
||||||
|
target_value: number;
|
||||||
|
target_date?: string | null;
|
||||||
|
}
|
||||||
38
frontend/src/types/kettlebell.ts
Normal file
38
frontend/src/types/kettlebell.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface ExerciseBlock {
|
||||||
|
order: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sets: number;
|
||||||
|
reps: number;
|
||||||
|
duration_seconds: number; // reps=0 → timed, duration_seconds=0 → rep-based
|
||||||
|
weight_kg: number;
|
||||||
|
rest_seconds: number;
|
||||||
|
coaching_tip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KettlebellSession {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
title: string;
|
||||||
|
focus: string;
|
||||||
|
exercises: { exercises: ExerciseBlock[] };
|
||||||
|
total_duration_min: number;
|
||||||
|
difficulty: string;
|
||||||
|
notes: string;
|
||||||
|
status: 'generated' | 'in_progress' | 'completed' | 'abandoned';
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KettlebellSetLog {
|
||||||
|
id: number;
|
||||||
|
session_id: number;
|
||||||
|
exercise_order: number;
|
||||||
|
set_number: number;
|
||||||
|
actual_reps: number;
|
||||||
|
actual_weight_kg: number;
|
||||||
|
actual_duration_seconds: number;
|
||||||
|
perceived_effort: number;
|
||||||
|
completed_at: string;
|
||||||
|
}
|
||||||
41
frontend/src/types/nutrition.ts
Normal file
41
frontend/src/types/nutrition.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface NutritionalInfo {
|
||||||
|
name: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fats: number;
|
||||||
|
reasoning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodLog {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fats: number;
|
||||||
|
image_url?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodLogCreate {
|
||||||
|
name: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fats: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NutritionSummary {
|
||||||
|
date: string;
|
||||||
|
total_calories: number;
|
||||||
|
total_protein: number;
|
||||||
|
total_carbs: number;
|
||||||
|
total_fats: number;
|
||||||
|
log_count: number;
|
||||||
|
target_calories?: number;
|
||||||
|
target_protein?: number;
|
||||||
|
target_carbs?: number;
|
||||||
|
target_fat?: number;
|
||||||
|
}
|
||||||
18
frontend/src/types/plans.ts
Normal file
18
frontend/src/types/plans.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface Plan {
|
||||||
|
id: number;
|
||||||
|
goal: string;
|
||||||
|
created_at: string;
|
||||||
|
content: string;
|
||||||
|
structured_content?: {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
diet_plan?: string[];
|
||||||
|
exercise_plan?: string[];
|
||||||
|
tips?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanRequest {
|
||||||
|
goal: string;
|
||||||
|
user_details: string;
|
||||||
|
}
|
||||||
45
frontend/src/types/supplement.ts
Normal file
45
frontend/src/types/supplement.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export interface Supplement {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
dosage: number;
|
||||||
|
unit: string;
|
||||||
|
frequency: string;
|
||||||
|
scheduled_times: string[];
|
||||||
|
notes?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplementWithStatus extends Supplement {
|
||||||
|
taken_today: boolean;
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplementLog {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
supplement_id: number;
|
||||||
|
taken_at: string;
|
||||||
|
dose_taken?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplementCreate {
|
||||||
|
name: string;
|
||||||
|
dosage: number;
|
||||||
|
unit: string;
|
||||||
|
frequency: string;
|
||||||
|
scheduled_times: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplementUpdate {
|
||||||
|
name?: string;
|
||||||
|
dosage?: number;
|
||||||
|
unit?: string;
|
||||||
|
frequency?: string;
|
||||||
|
scheduled_times?: string[];
|
||||||
|
notes?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
33
frontend/src/types/user.ts
Normal file
33
frontend/src/types/user.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
age?: number;
|
||||||
|
gender?: string;
|
||||||
|
height?: number;
|
||||||
|
weight?: number;
|
||||||
|
unit_preference: string;
|
||||||
|
target_calories?: number;
|
||||||
|
target_protein?: number;
|
||||||
|
target_carbs?: number;
|
||||||
|
target_fat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUpdate {
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
age?: number;
|
||||||
|
gender?: string;
|
||||||
|
height?: number;
|
||||||
|
weight?: number;
|
||||||
|
unit_preference?: string;
|
||||||
|
target_calories?: number;
|
||||||
|
target_protein?: number;
|
||||||
|
target_carbs?: number;
|
||||||
|
target_fat?: number;
|
||||||
|
}
|
||||||
@@ -2,11 +2,34 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react-swc';
|
import react from '@vitejs/plugin-react-swc';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
manifest: {
|
||||||
|
name: 'HealthyFit',
|
||||||
|
short_name: 'HealthyFit',
|
||||||
|
description: 'AI-powered health & fitness tracker',
|
||||||
|
theme_color: '#556B2F',
|
||||||
|
background_color: '#FDFBF7',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: [], // no precaching — push handling only
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user