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:
Carlos Escalante
2026-03-20 18:57:03 -06:00
parent bd91eb4171
commit f279907ae3
61 changed files with 9256 additions and 85 deletions

88
CLAUDE.md Normal file
View 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

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

View File

@@ -1,6 +1,6 @@
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.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(health.router, prefix="/health", tags=["health"])
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"])

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

View 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

View File

@@ -1,9 +1,11 @@
from datetime import date as date_type
from datetime import datetime
from typing import Any
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 sqlmodel import Session
from sqlmodel import Session, select
from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module
from app.api import deps
@@ -16,9 +18,18 @@ class AnalyzeRequest(BaseModel):
description: str
class FoodLogCreate(BaseModel):
name: str
calories: float
protein: float
carbs: float
fats: float
@router.post("/analyze", response_model=NutritionalInfo)
def analyze_food(
request: AnalyzeRequest,
current_user: deps.CurrentUser,
) -> Any:
"""
Analyze food description and return nutritional info using DSPy.
@@ -32,6 +43,7 @@ def analyze_food(
@router.post("/analyze/image", response_model=NutritionalInfo)
async def analyze_food_image(
current_user: deps.CurrentUser,
file: UploadFile = File(...),
description: str = Form(""),
) -> Any:
@@ -51,7 +63,7 @@ async def analyze_food_image(
def log_food(
*,
session: Session = Depends(deps.get_session),
nutrition_info: NutritionalInfo,
nutrition_info: FoodLogCreate,
current_user: deps.CurrentUser,
) -> Any:
"""
@@ -81,8 +93,6 @@ def read_logs(
"""
Get food logs for current user.
"""
from sqlmodel import select
statement = (
select(FoodLog)
.where(FoodLog.user_id == current_user.id)
@@ -91,3 +101,55 @@ def read_logs(
.limit(limit)
)
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),
)

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

View 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

View File

@@ -59,6 +59,8 @@ def update_user_me(
Update own user.
"""
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)
session.add(current_user)
session.commit()

View 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 # 110
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)

View 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 # 110 RPE
completed_at: datetime = Field(default_factory=datetime.utcnow)

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

View 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

View File

@@ -19,4 +19,10 @@ class User(SQLModel, table=True):
weight: Optional[float] = None
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)

47
backend/app/scheduler.py Normal file
View 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)

View File

@@ -21,6 +21,10 @@ class UserRead(UserBase):
height: Optional[float] = None
weight: Optional[float] = None
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):
@@ -34,3 +38,7 @@ class UserUpdate(SQLModel):
height: Optional[float] = None
weight: Optional[float] = 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

View File

@@ -13,4 +13,7 @@ pytest
httpx
python-dotenv
ruff
pywebpush
apscheduler
pytz

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,12 @@
"lucide-react": "^0.562.0",
"motion": "^12.27.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0"
"recharts": "^3.6.0",
"sonner": "^2.0.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -35,6 +37,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

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

View File

@@ -1,12 +1,18 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { Toaster } from 'sonner';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Nutrition from './pages/Nutrition';
import Health from './pages/Health';
import Plans from './pages/Plans';
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 AppLayout from './components/Layout/AppLayout';
@@ -15,6 +21,7 @@ function App() {
<ThemeProvider>
<AuthProvider>
<Router>
<Toaster richColors position="top-right" />
<div className="min-h-screen font-sans bg-base text-content transition-colors duration-200">
<Routes>
<Route path="/login" element={<Login />} />
@@ -55,6 +62,42 @@ function App() {
</AppLayout>
</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>
</div>
</Router>

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

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

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

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

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

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

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

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

View File

@@ -14,10 +14,13 @@ import {
Utensils,
Heart,
Calendar,
CalendarDays,
User,
Moon,
Sun,
LogOut,
Dumbbell,
Pill,
} from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { useTheme } from '../../context/ThemeContext'
@@ -34,6 +37,9 @@ export function AppSidebar() {
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
{ 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: "/profile", icon: User, label: "Profile" },
]

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

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

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

View 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} &nbsp;·&nbsp; Set {setIdx + 1}/{totalSets}
</div>
);
}

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

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

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

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

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

View File

@@ -1,25 +1,32 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import client from '../api/client';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
BarChart, Bar, PieChart, Pie, Cell, Legend
PieChart, Pie, Cell, Legend
} from 'recharts';
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 [loading, setLoading] = useState(true);
const [metrics, setMetrics] = useState([]);
const [logs, setLogs] = useState([]);
const [metrics, setMetrics] = useState<HealthMetric[]>([]);
const [logs, setLogs] = useState<FoodLog[]>([]);
const [summary, setSummary] = useState<NutritionSummary | null>(null);
useEffect(() => {
const loadData = async () => {
try {
const [metricsRes, logsRes] = await Promise.all([
const [metricsRes, logsRes, summaryData] = await Promise.all([
client.get('/health/metrics'),
client.get('/nutrition/logs')
client.get('/nutrition/logs'),
getNutritionSummary(),
]);
setMetrics(metricsRes.data);
setLogs(logsRes.data);
setSummary(summaryData);
} catch (error) {
console.error("Failed to load dashboard data", error);
} finally {
@@ -171,7 +178,7 @@ const Dashboard = () => {
paddingAngle={5}
dataKey="value"
>
{activeMacroData.map((entry, index) => (
{activeMacroData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@@ -191,6 +198,41 @@ const Dashboard = () => {
</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 */}
<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>

View File

@@ -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 { 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 { Select } from '../components/catalyst/select';
import { Button } from '../components/catalyst/button';
@@ -56,7 +57,7 @@ const Health = () => {
fetchData();
} catch (error) {
console.error(error);
alert('Failed to add metric');
toast.error('Failed to add metric');
} finally {
setLoading(false);
}
@@ -74,7 +75,7 @@ const Health = () => {
fetchData();
} catch (error) {
console.error(error);
alert('Failed to add goal');
toast.error('Failed to add goal');
} finally {
setLoading(false);
}

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

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

View File

@@ -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 {
Upload,
@@ -14,7 +15,7 @@ import {
Info,
Utensils
} from 'lucide-react';
import { Field, Label } from '../components/catalyst/fieldset';
import { Field } from '../components/catalyst/fieldset';
import { Textarea } from '../components/catalyst/textarea';
import { Button } from '../components/catalyst/button';
import { Heading } from '../components/catalyst/heading';
@@ -78,7 +79,7 @@ const Nutrition = () => {
setShowReasoning(false);
} catch (error) {
console.error(error);
alert('Failed to analyze. Please try again.');
toast.error('Failed to analyze. Please try again.');
} finally {
setLoading(false);
}
@@ -91,10 +92,10 @@ const Nutrition = () => {
setAnalysis(null);
setDescription('');
clearFile();
alert('Meal logged successfully!');
toast.success('Meal logged successfully!');
} catch (error) {
console.error(error);
alert('Failed to save log.');
toast.error('Failed to save log.');
}
};

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, FormEvent } from 'react';
import { toast } from 'sonner';
import client from '../api/client';
import { Field, Label } from '../components/catalyst/fieldset';
import { Input } from '../components/catalyst/input';
@@ -52,7 +53,7 @@ const Plans = () => {
setUserDetails('');
} catch (error) {
console.error(error);
alert('Failed to generate plan');
toast.error('Failed to generate plan');
} finally {
setLoading(false);
}

View File

@@ -1,11 +1,13 @@
import { useState, useEffect, useContext, FormEvent, ChangeEvent } from 'react';
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 { Input } from '../components/catalyst/input';
import { Select } from '../components/catalyst/select';
import { Button } from '../components/catalyst/button';
import { Heading } from '../components/catalyst/heading';
import { getVapidPublicKey, subscribePush, unsubscribePush, sendTestNotification, urlBase64ToUint8Array } from '../api/push';
interface FormData {
firstname: string;
@@ -22,9 +24,21 @@ interface FormData {
const Profile = () => {
const { user, updateUser } = useContext(AuthContext);
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'
// 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>({
firstname: '',
lastname: '',
@@ -41,6 +55,12 @@ const Profile = () => {
if (user) {
const prefs = user.unit_preference || 'metric';
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 w_kg = user.weight || '';
@@ -70,6 +90,86 @@ const Profile = () => {
}
}, [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) => {
setUnitSystem(system);
};
@@ -108,9 +208,8 @@ const Profile = () => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage({ type: '', text: '' });
const payload = {
const payload: Record<string, unknown> = {
firstname: formData.firstname,
lastname: formData.lastname,
age: parseInt(formData.age),
@@ -119,12 +218,16 @@ const Profile = () => {
height: parseFloat(String(formData.height_cm)),
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);
if (success) {
setMessage({ type: 'success', text: 'Profile updated successfully!' });
toast.success('Profile updated successfully!');
} else {
setMessage({ type: 'error', text: 'Failed to update profile.' });
toast.error('Failed to update profile.');
}
setLoading(false);
};
@@ -137,14 +240,6 @@ const Profile = () => {
</header>
<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">
{/* Personal Info */}
<Fieldset>
@@ -298,6 +393,122 @@ const Profile = () => {
</div>
</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 (023)</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">
<Button
type="submit"

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

View 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.01.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[];
}

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

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

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

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

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

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

View File

@@ -2,11 +2,34 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
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: {
alias: {