This commit is contained in:
xds
2026-03-16 12:12:56 +03:00
commit 9d886076d6
63 changed files with 4482 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(cd:*)",
"Bash(which node:*)",
"WebSearch",
"WebFetch(domain:gearboxgo.com)",
"WebFetch(domain:primevue.org)",
"WebFetch(domain:core.telegram.org)",
"WebFetch(domain:docs.telegram-mini-apps.com)"
]
}
}

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Database
DATABASE_URL=postgresql+asyncpg://velobrain:velobrain@localhost:5432/velobrain
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
# Gemini
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-pro
# App
APP_SECRET_KEY=change-me-in-production
DEBUG=true
# Auth / JWT
JWT_SECRET_KEY=change-me-jwt-secret
# Telegram Bot
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
# Upload
UPLOAD_DIR=./uploads

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
.venv/
venv/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
# OS
.DS_Store
Thumbs.db
# Alembic
backend/alembic/versions/__pycache__/
# Node
frontend/node_modules/
frontend/dist/
# Uploads
backend/uploads/
# Tests
.pytest_cache/
.coverage
htmlcov/

36
backend/alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://velobrain:velobrain@localhost:5432/velobrain
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

58
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,58 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.config import settings
from backend.app.core.database import Base
# Import all models so they register with Base.metadata
from backend.app.models import * # noqa: F401, F403
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,102 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
from backend.app.core.database import get_session
from backend.app.models.activity import Activity, DataPoint
from backend.app.schemas.activity import (
ActivityResponse,
ActivityListResponse,
DataPointResponse,
)
from backend.app.services.fit_parser import parse_fit_file
from backend.app.services.metrics import calculate_metrics
router = APIRouter()
@router.post("/upload", response_model=ActivityResponse)
async def upload_activity(
rider_id: uuid.UUID,
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
):
if not file.filename or not file.filename.lower().endswith(".fit"):
raise HTTPException(status_code=400, detail="Only .FIT files are accepted")
upload_dir = Path(settings.UPLOAD_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
file_id = uuid.uuid4()
file_path = upload_dir / f"{file_id}.fit"
content = await file.read()
file_path.write_bytes(content)
activity, data_points = parse_fit_file(content, rider_id, str(file_path))
session.add(activity)
await session.flush()
for dp in data_points:
dp.activity_id = activity.id
session.add_all(data_points)
metrics = calculate_metrics(data_points, activity, rider_id, session)
if metrics:
session.add(metrics)
await session.commit()
await session.refresh(activity)
return activity
@router.get("", response_model=ActivityListResponse)
async def list_activities(
rider_id: uuid.UUID,
limit: int = 20,
offset: int = 0,
session: AsyncSession = Depends(get_session),
):
count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider_id)
total = (await session.execute(count_query)).scalar() or 0
query = (
select(Activity)
.where(Activity.rider_id == rider_id)
.order_by(Activity.date.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(query)
activities = result.scalars().all()
return ActivityListResponse(items=activities, total=total)
@router.get("/{activity_id}", response_model=ActivityResponse)
async def get_activity(
activity_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@router.get("/{activity_id}/stream", response_model=list[DataPointResponse])
async def get_activity_stream(
activity_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
.order_by(DataPoint.timestamp)
)
result = await session.execute(query)
return result.scalars().all()

117
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
from backend.app.core.database import get_session
from backend.app.core.security import (
create_access_token,
verify_telegram_login,
verify_telegram_webapp,
)
from backend.app.models.rider import Rider
from backend.app.schemas.auth import (
AuthResponse,
TelegramLoginRequest,
TelegramWebAppRequest,
)
router = APIRouter()
async def _upsert_rider(
session: AsyncSession,
telegram_id: int,
first_name: str,
last_name: str | None,
username: str | None,
photo_url: str | None,
) -> Rider:
result = await session.execute(
select(Rider).where(Rider.telegram_id == telegram_id)
)
rider = result.scalar_one_or_none()
name = first_name
if last_name:
name = f"{first_name} {last_name}"
if not rider:
rider = Rider(
telegram_id=telegram_id,
name=name,
telegram_username=username,
avatar_url=photo_url,
)
session.add(rider)
else:
rider.name = name
rider.telegram_username = username
rider.avatar_url = photo_url
await session.commit()
await session.refresh(rider)
return rider
def _build_auth_response(rider: Rider) -> AuthResponse:
token = create_access_token(
rider_id=str(rider.id),
telegram_id=rider.telegram_id,
secret=settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM,
expires_minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
)
return AuthResponse(access_token=token, rider=rider)
@router.post("/telegram-login", response_model=AuthResponse)
async def telegram_login(
data: TelegramLoginRequest,
session: AsyncSession = Depends(get_session),
):
if not settings.TELEGRAM_BOT_TOKEN:
raise HTTPException(status_code=500, detail="Telegram bot token not configured")
login_data = data.model_dump()
if not verify_telegram_login(login_data, settings.TELEGRAM_BOT_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Telegram authorization",
)
rider = await _upsert_rider(
session,
telegram_id=data.id,
first_name=data.first_name,
last_name=data.last_name,
username=data.username,
photo_url=data.photo_url,
)
return _build_auth_response(rider)
@router.post("/telegram-webapp", response_model=AuthResponse)
async def telegram_webapp(
data: TelegramWebAppRequest,
session: AsyncSession = Depends(get_session),
):
if not settings.TELEGRAM_BOT_TOKEN:
raise HTTPException(status_code=500, detail="Telegram bot token not configured")
user = verify_telegram_webapp(data.init_data, settings.TELEGRAM_BOT_TOKEN)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Telegram WebApp data",
)
rider = await _upsert_rider(
session,
telegram_id=user["id"],
first_name=user.get("first_name", ""),
last_name=user.get("last_name"),
username=user.get("username"),
photo_url=user.get("photo_url"),
)
return _build_auth_response(rider)

52
backend/app/api/rider.py Normal file
View File

@@ -0,0 +1,52 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_session
from backend.app.models.rider import Rider
from backend.app.schemas.rider import RiderCreate, RiderUpdate, RiderResponse
router = APIRouter()
@router.post("/profile", response_model=RiderResponse)
async def create_rider(
data: RiderCreate,
session: AsyncSession = Depends(get_session),
):
rider = Rider(**data.model_dump())
session.add(rider)
await session.commit()
await session.refresh(rider)
return rider
@router.get("/profile/{rider_id}", response_model=RiderResponse)
async def get_rider(
rider_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
return rider
@router.put("/profile/{rider_id}", response_model=RiderResponse)
async def update_rider(
rider_id: uuid.UUID,
data: RiderUpdate,
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rider, key, value)
await session.commit()
await session.refresh(rider)
return rider

11
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from backend.app.api.auth import router as auth_router
from backend.app.api.activities import router as activities_router
from backend.app.api.rider import router as rider_router
api_router = APIRouter(prefix="/api")
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
api_router.include_router(activities_router, prefix="/activities", tags=["activities"])
api_router.include_router(rider_router, prefix="/rider", tags=["rider"])

View File

36
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
from backend.app.core.database import get_session
from backend.app.core.security import decode_access_token
from backend.app.models.rider import Rider
bearer_scheme = HTTPBearer()
async def get_current_rider(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
session: AsyncSession = Depends(get_session),
) -> Rider:
try:
payload = decode_access_token(
credentials.credentials,
settings.JWT_SECRET_KEY,
settings.JWT_ALGORITHM,
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
rider = await session.get(Rider, payload["sub"])
if not rider:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Rider not found",
)
return rider

View File

@@ -0,0 +1,34 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
# Database
DATABASE_URL: str = "postgresql+asyncpg://velobrain:velobrain@localhost:5432/velobrain"
# Anthropic
ANTHROPIC_API_KEY: str = ""
# Gemini
GEMINI_API_KEY: str = ""
GEMINI_MODEL: str = "gemini-2.5-pro"
# App
APP_SECRET_KEY: str = "change-me-in-production"
DEBUG: bool = True
# Auth / JWT
JWT_SECRET_KEY: str = "change-me-jwt-secret"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
# Upload
UPLOAD_DIR: str = "./uploads"
settings = Settings()

View File

@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from backend.app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_session() -> AsyncSession:
async with async_session() as session:
yield session

View File

@@ -0,0 +1,71 @@
import hashlib
import hmac
import json
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, unquote
import jwt
def verify_telegram_login(data: dict, bot_token: str) -> bool:
"""Verify data from Telegram Login Widget."""
data = dict(data)
check_hash = data.pop("hash", "")
if not check_hash:
return False
data_check_string = "\n".join(
f"{k}={v}" for k, v in sorted(data.items())
)
secret_key = hashlib.sha256(bot_token.encode()).digest()
computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if int(data.get("auth_date", 0)) < time.time() - 86400:
return False
return hmac.compare_digest(computed, check_hash)
def verify_telegram_webapp(init_data: str, bot_token: str) -> dict | None:
"""Verify Telegram WebApp initData and return parsed user dict."""
parsed = parse_qs(init_data)
data = {k: v[0] for k, v in parsed.items()}
check_hash = data.pop("hash", "")
if not check_hash:
return None
data_check_string = "\n".join(
f"{k}={v}" for k, v in sorted(data.items())
)
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(computed, check_hash):
return None
user_raw = data.get("user")
if not user_raw:
return None
return json.loads(unquote(user_raw))
def create_access_token(
rider_id: str,
telegram_id: int,
secret: str,
algorithm: str,
expires_minutes: int,
) -> str:
payload = {
"sub": rider_id,
"tg_id": telegram_id,
"exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
}
return jwt.encode(payload, secret, algorithm=algorithm)
def decode_access_token(token: str, secret: str, algorithm: str) -> dict:
return jwt.decode(token, secret, algorithms=[algorithm])

37
backend/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.app.api.router import api_router
from backend.app.core.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
yield
# Shutdown
app = FastAPI(
title="VeloBrain",
description="AI-Powered Cycling Training Platform",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,16 @@
from backend.app.models.rider import Rider
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval
from backend.app.models.fitness import FitnessHistory, PowerCurve, DiaryEntry
from backend.app.models.training import TrainingPlan
__all__ = [
"Rider",
"Activity",
"ActivityMetrics",
"DataPoint",
"Interval",
"FitnessHistory",
"PowerCurve",
"DiaryEntry",
"TrainingPlan",
]

View File

@@ -0,0 +1,84 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Float, Integer, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class Activity(Base):
__tablename__ = "activities"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
rider_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("riders.id"))
name: Mapped[str | None] = mapped_column(String(200), nullable=True)
activity_type: Mapped[str] = mapped_column(String(50), default="road")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
duration: Mapped[int] = mapped_column(Integer) # seconds
distance: Mapped[float | None] = mapped_column(Float, nullable=True) # meters
elevation_gain: Mapped[float | None] = mapped_column(Float, nullable=True) # meters
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
rider = relationship("Rider", back_populates="activities")
metrics = relationship("ActivityMetrics", back_populates="activity", uselist=False, lazy="joined")
intervals = relationship("Interval", back_populates="activity", lazy="selectin")
data_points = relationship("DataPoint", back_populates="activity", lazy="noload")
class ActivityMetrics(Base):
__tablename__ = "activity_metrics"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
activity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("activities.id"), unique=True)
tss: Mapped[float | None] = mapped_column(Float, nullable=True)
normalized_power: Mapped[float | None] = mapped_column(Float, nullable=True)
intensity_factor: Mapped[float | None] = mapped_column(Float, nullable=True)
variability_index: Mapped[float | None] = mapped_column(Float, nullable=True)
avg_power: Mapped[float | None] = mapped_column(Float, nullable=True)
max_power: Mapped[int | None] = mapped_column(Integer, nullable=True)
avg_hr: Mapped[int | None] = mapped_column(Integer, nullable=True)
max_hr: Mapped[int | None] = mapped_column(Integer, nullable=True)
avg_cadence: Mapped[int | None] = mapped_column(Integer, nullable=True)
avg_speed: Mapped[float | None] = mapped_column(Float, nullable=True) # m/s
calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
activity = relationship("Activity", back_populates="metrics")
class DataPoint(Base):
__tablename__ = "data_points"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
activity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("activities.id"))
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
power: Mapped[int | None] = mapped_column(Integer, nullable=True)
heart_rate: Mapped[int | None] = mapped_column(Integer, nullable=True)
cadence: Mapped[int | None] = mapped_column(Integer, nullable=True)
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
altitude: Mapped[float | None] = mapped_column(Float, nullable=True)
temperature: Mapped[int | None] = mapped_column(Integer, nullable=True)
activity = relationship("Activity", back_populates="data_points")
class Interval(Base):
__tablename__ = "intervals"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
activity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("activities.id"))
start_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True))
interval_type: Mapped[str] = mapped_column(String(50)) # work / rest / climb
avg_power: Mapped[float | None] = mapped_column(Float, nullable=True)
avg_hr: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) # seconds
activity = relationship("Activity", back_populates="intervals")

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import date, datetime
from sqlalchemy import String, Float, Integer, Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class FitnessHistory(Base):
__tablename__ = "fitness_history"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
rider_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("riders.id"))
date: Mapped[date] = mapped_column(Date, index=True)
ctl: Mapped[float] = mapped_column(Float, default=0)
atl: Mapped[float] = mapped_column(Float, default=0)
tsb: Mapped[float] = mapped_column(Float, default=0)
ramp_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
class PowerCurve(Base):
__tablename__ = "power_curves"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
activity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("activities.id"))
curve_data: Mapped[dict] = mapped_column(JSONB) # {duration_seconds: max_power}
class DiaryEntry(Base):
__tablename__ = "diary_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
activity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("activities.id"), unique=True)
ai_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
rider_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
mood: Mapped[str | None] = mapped_column(String(50), nullable=True)
rpe: Mapped[int | None] = mapped_column(Integer, nullable=True)
sleep_hours: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Float, BigInteger, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class Rider(Base):
__tablename__ = "riders"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, index=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
name: Mapped[str] = mapped_column(String(100))
ftp: Mapped[float | None] = mapped_column(Float, nullable=True)
lthr: Mapped[int | None] = mapped_column(nullable=True)
weight: Mapped[float | None] = mapped_column(Float, nullable=True)
zones_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
goals: Mapped[str | None] = mapped_column(String(500), nullable=True)
experience_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
activities = relationship("Activity", back_populates="rider", lazy="selectin")

View File

@@ -0,0 +1,23 @@
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class TrainingPlan(Base):
__tablename__ = "training_plans"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
rider_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("riders.id"))
goal: Mapped[str] = mapped_column(String(200))
start_date: Mapped[date] = mapped_column(Date)
end_date: Mapped[date] = mapped_column(Date)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
weeks_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

View File

@@ -0,0 +1,52 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class ActivityMetricsResponse(BaseModel):
model_config = {"from_attributes": True}
tss: float | None = None
normalized_power: float | None = None
intensity_factor: float | None = None
variability_index: float | None = None
avg_power: float | None = None
max_power: int | None = None
avg_hr: int | None = None
max_hr: int | None = None
avg_cadence: int | None = None
avg_speed: float | None = None
class ActivityResponse(BaseModel):
model_config = {"from_attributes": True}
id: UUID
rider_id: UUID
name: str | None = None
activity_type: str
date: datetime
duration: int
distance: float | None = None
elevation_gain: float | None = None
metrics: ActivityMetricsResponse | None = None
class ActivityListResponse(BaseModel):
items: list[ActivityResponse]
total: int
class DataPointResponse(BaseModel):
model_config = {"from_attributes": True}
timestamp: datetime
power: int | None = None
heart_rate: int | None = None
cadence: int | None = None
speed: float | None = None
latitude: float | None = None
longitude: float | None = None
altitude: float | None = None
temperature: int | None = None

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel
from backend.app.schemas.rider import RiderResponse
class TelegramLoginRequest(BaseModel):
id: int
first_name: str
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int
hash: str
class TelegramWebAppRequest(BaseModel):
init_data: str
class AuthResponse(BaseModel):
access_token: str
token_type: str = "bearer"
rider: RiderResponse

View File

@@ -0,0 +1,38 @@
from uuid import UUID
from pydantic import BaseModel
class RiderCreate(BaseModel):
name: str
ftp: float | None = None
lthr: int | None = None
weight: float | None = None
goals: str | None = None
experience_level: str | None = None
class RiderUpdate(BaseModel):
name: str | None = None
ftp: float | None = None
lthr: int | None = None
weight: float | None = None
zones_config: dict | None = None
goals: str | None = None
experience_level: str | None = None
class RiderResponse(BaseModel):
model_config = {"from_attributes": True}
id: UUID
telegram_id: int | None = None
telegram_username: str | None = None
avatar_url: str | None = None
name: str
ftp: float | None = None
lthr: int | None = None
weight: float | None = None
zones_config: dict | None = None
goals: str | None = None
experience_level: str | None = None

View File

View File

@@ -0,0 +1,102 @@
import uuid
from datetime import datetime, timezone
from io import BytesIO
import fitdecode
from backend.app.models.activity import Activity, DataPoint
def parse_fit_file(
file_content: bytes,
rider_id: uuid.UUID,
file_path: str,
) -> tuple[Activity, list[DataPoint]]:
"""Parse a .FIT file and return an Activity with its DataPoints."""
data_points: list[DataPoint] = []
session_data: dict = {}
with fitdecode.FitReader(BytesIO(file_content)) as fit:
for frame in fit:
if not isinstance(frame, fitdecode.FitDataMessage):
continue
if frame.name == "record":
dp = _parse_record(frame)
if dp:
data_points.append(dp)
elif frame.name == "session":
session_data = _parse_session(frame)
start_time = data_points[0].timestamp if data_points else datetime.now(timezone.utc)
end_time = data_points[-1].timestamp if data_points else start_time
duration = int((end_time - start_time).total_seconds()) if data_points else 0
activity = Activity(
rider_id=rider_id,
name=session_data.get("sport", "Ride"),
activity_type=session_data.get("sub_sport", "road"),
date=start_time,
duration=duration,
distance=session_data.get("total_distance"),
elevation_gain=session_data.get("total_ascent"),
file_path=file_path,
)
return activity, data_points
def _parse_record(frame: fitdecode.FitDataMessage) -> DataPoint | None:
"""Parse a single record message into a DataPoint."""
timestamp = _get_field(frame, "timestamp")
if not timestamp:
return None
if isinstance(timestamp, datetime) and timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
return DataPoint(
timestamp=timestamp,
power=_get_field(frame, "power"),
heart_rate=_get_field(frame, "heart_rate"),
cadence=_get_field(frame, "cadence"),
speed=_get_field(frame, "speed"),
latitude=_semicircles_to_degrees(_get_field(frame, "position_lat")),
longitude=_semicircles_to_degrees(_get_field(frame, "position_long")),
altitude=_get_field(frame, "altitude"),
temperature=_get_field(frame, "temperature"),
)
def _parse_session(frame: fitdecode.FitDataMessage) -> dict:
"""Extract session-level data from FIT session message."""
return {
"sport": _get_field_str(frame, "sport"),
"sub_sport": _get_field_str(frame, "sub_sport"),
"total_distance": _get_field(frame, "total_distance"),
"total_ascent": _get_field(frame, "total_ascent"),
"total_elapsed_time": _get_field(frame, "total_elapsed_time"),
}
def _get_field(frame: fitdecode.FitDataMessage, name: str):
"""Safely get a field value from a FIT frame."""
try:
field = frame.get_field(name)
return field.value if field else None
except KeyError:
return None
def _get_field_str(frame: fitdecode.FitDataMessage, name: str) -> str | None:
"""Get field value as string."""
val = _get_field(frame, name)
return str(val) if val is not None else None
def _semicircles_to_degrees(semicircles: int | None) -> float | None:
"""Convert Garmin semicircles to decimal degrees."""
if semicircles is None:
return None
return semicircles * (180.0 / 2**31)

View File

@@ -0,0 +1,126 @@
from google import genai
from google.genai import types
from backend.app.core.config import settings
_client: genai.Client | None = None
def get_client() -> genai.Client:
global _client
if _client is None:
_client = genai.Client(api_key=settings.GEMINI_API_KEY)
return _client
def chat_sync(
messages: list[dict[str, str]],
system_instruction: str | None = None,
temperature: float = 0.7,
max_tokens: int = 8192,
) -> str:
"""
Synchronous chat with Gemini.
messages: list of {"role": "user"|"model", "text": "..."}
Returns the model's text response.
"""
client = get_client()
contents = [
types.Content(
role=m["role"],
parts=[types.Part.from_text(text=m["text"])],
)
for m in messages
]
config = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_instruction:
config.system_instruction = system_instruction
response = client.models.generate_content(
model=settings.GEMINI_MODEL,
contents=contents,
config=config,
)
return response.text or ""
async def chat_async(
messages: list[dict[str, str]],
system_instruction: str | None = None,
temperature: float = 0.7,
max_tokens: int = 8192,
) -> str:
"""
Async chat with Gemini.
messages: list of {"role": "user"|"model", "text": "..."}
Returns the model's text response.
"""
client = get_client()
contents = [
types.Content(
role=m["role"],
parts=[types.Part.from_text(text=m["text"])],
)
for m in messages
]
config = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_instruction:
config.system_instruction = system_instruction
response = await client.aio.models.generate_content(
model=settings.GEMINI_MODEL,
contents=contents,
config=config,
)
return response.text or ""
async def chat_stream(
messages: list[dict[str, str]],
system_instruction: str | None = None,
temperature: float = 0.7,
max_tokens: int = 8192,
):
"""
Async streaming chat with Gemini. Yields text chunks.
messages: list of {"role": "user"|"model", "text": "..."}
"""
client = get_client()
contents = [
types.Content(
role=m["role"],
parts=[types.Part.from_text(text=m["text"])],
)
for m in messages
]
config = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_instruction:
config.system_instruction = system_instruction
async for chunk in client.aio.models.generate_content_stream(
model=settings.GEMINI_MODEL,
contents=contents,
config=config,
):
if chunk.text:
yield chunk.text

View File

@@ -0,0 +1,83 @@
import uuid
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint
from backend.app.models.rider import Rider
def calculate_metrics(
data_points: list[DataPoint],
activity: Activity,
rider_id: uuid.UUID,
session: AsyncSession,
) -> ActivityMetrics | None:
"""Calculate power-based metrics for an activity."""
if not data_points:
return None
powers = np.array([dp.power for dp in data_points if dp.power is not None], dtype=float)
hrs = np.array([dp.heart_rate for dp in data_points if dp.heart_rate is not None], dtype=float)
cadences = np.array([dp.cadence for dp in data_points if dp.cadence is not None], dtype=float)
speeds = np.array([dp.speed for dp in data_points if dp.speed is not None], dtype=float)
avg_power = float(np.mean(powers)) if len(powers) > 0 else None
max_power = int(np.max(powers)) if len(powers) > 0 else None
np_value = _normalized_power(powers) if len(powers) >= 30 else avg_power
avg_hr = int(np.mean(hrs)) if len(hrs) > 0 else None
max_hr = int(np.max(hrs)) if len(hrs) > 0 else None
avg_cadence = int(np.mean(cadences)) if len(cadences) > 0 else None
avg_speed = float(np.mean(speeds)) if len(speeds) > 0 else None
# IF, VI, TSS require FTP — will be None if no FTP set
intensity_factor = None
variability_index = None
tss = None
if np_value and avg_power and avg_power > 0:
variability_index = np_value / avg_power
return ActivityMetrics(
activity_id=activity.id,
tss=tss,
normalized_power=round(np_value, 1) if np_value else None,
intensity_factor=intensity_factor,
variability_index=round(variability_index, 2) if variability_index else None,
avg_power=round(avg_power, 1) if avg_power else None,
max_power=max_power,
avg_hr=avg_hr,
max_hr=max_hr,
avg_cadence=avg_cadence,
avg_speed=round(avg_speed, 2) if avg_speed else None,
)
def calculate_metrics_with_ftp(
metrics: ActivityMetrics,
ftp: float,
duration_seconds: int,
) -> ActivityMetrics:
"""Enrich metrics with FTP-dependent values (IF, TSS)."""
if metrics.normalized_power and ftp > 0:
metrics.intensity_factor = round(metrics.normalized_power / ftp, 2)
metrics.tss = round(
(duration_seconds * metrics.normalized_power * metrics.intensity_factor)
/ (ftp * 3600)
* 100,
1,
)
return metrics
def _normalized_power(powers: np.ndarray) -> float:
"""
NP = 4th root of mean of 4th powers of 30s rolling average.
"""
if len(powers) < 30:
return float(np.mean(powers))
rolling = np.convolve(powers, np.ones(30) / 30, mode="valid")
return float(np.power(np.mean(np.power(rolling, 4)), 0.25))

34
backend/requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
# Web framework
fastapi==0.115.12
uvicorn[standard]==0.34.2
python-multipart==0.0.20
# Database
sqlalchemy[asyncio]==2.0.41
asyncpg==0.30.0
alembic==1.15.2
# FIT parsing
fitdecode==0.10.0
# Analytics
numpy==2.2.4
pandas==2.2.3
# AI
anthropic==0.52.0
google-genai==1.67.0
# Config
pydantic-settings==2.9.1
# Telegram bot
aiogram==3.20.0
# Auth
PyJWT==2.10.1
# Testing
pytest==8.3.5
pytest-asyncio==0.25.3
httpx==0.28.1

View File

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="app-dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VeloBrain</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2490
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^2.0.3",
"@types/leaflet": "^1.9.21",
"axios": "^1.13.6",
"echarts": "^6.0.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.30",
"vue-echarts": "^8.0.1",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.2.5"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

31
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { computed } from 'vue'
import { useAuthStore } from './stores/auth'
import Button from 'primevue/button'
import Avatar from 'primevue/avatar'
const auth = useAuthStore()
const isAuthenticated = computed(() => auth.isAuthenticated)
</script>
<template>
<div class="min-h-screen bg-surface-950 text-surface-0">
<nav v-if="isAuthenticated" class="flex items-center justify-between px-8 h-14 bg-surface-900 border-b border-surface-700">
<RouterLink to="/" class="text-xl font-bold text-primary">VeloBrain</RouterLink>
<div class="flex items-center gap-6">
<RouterLink to="/" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Dashboard</RouterLink>
<RouterLink to="/activities" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Activities</RouterLink>
<RouterLink to="/settings" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Settings</RouterLink>
<div class="flex items-center gap-3 ml-4">
<Avatar v-if="auth.rider?.avatar_url" :image="auth.rider.avatar_url" shape="circle" size="small" />
<Avatar v-else :label="auth.rider?.name?.charAt(0) ?? '?'" shape="circle" size="small" />
<Button icon="pi pi-sign-out" text rounded severity="secondary" size="small" @click="auth.logout()" />
</div>
</div>
</nav>
<main class="max-w-[1200px] mx-auto p-8">
<RouterView />
</main>
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,28 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export function useApi() {
return { api }
}

26
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
cssLayer: {
name: 'primevue',
order: 'theme, base, primevue',
},
darkModeSelector: '.app-dark',
},
},
})
app.mount('#app')

52
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
const routes = [
{
path: '/login',
name: 'login',
component: () => import('./views/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
name: 'dashboard',
component: () => import('./views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/activities',
name: 'activities',
component: () => import('./views/ActivitiesView.vue'),
meta: { requiresAuth: true },
},
{
path: '/activities/:id',
name: 'activity-detail',
component: () => import('./views/ActivityDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
return { name: 'login' }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
})
export default router

View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { Activity } from '../types/models'
export const useActivitiesStore = defineStore('activities', () => {
const { api } = useApi()
const activities = ref<Activity[]>([])
const total = ref(0)
async function fetchActivities(riderId: string, limit = 20, offset = 0) {
const { data } = await api.get('/activities', {
params: { rider_id: riderId, limit, offset },
})
activities.value = data.items
total.value = data.total
}
async function uploadFit(riderId: string, file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await api.post<Activity>(
`/activities/upload?rider_id=${riderId}`,
form,
)
activities.value.unshift(data)
total.value++
return data
}
return { activities, total, fetchActivities, uploadFit }
})

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi'
import type { Rider } from '../types/models'
import router from '../router'
interface AuthResponse {
access_token: string
token_type: string
rider: Rider
}
export const useAuthStore = defineStore('auth', () => {
const { api } = useApi()
const token = ref<string | null>(localStorage.getItem('access_token'))
const rider = ref<Rider | null>(null)
const isAuthenticated = computed(() => !!token.value)
function setAuth(response: AuthResponse) {
token.value = response.access_token
rider.value = response.rider
localStorage.setItem('access_token', response.access_token)
}
function logout() {
token.value = null
rider.value = null
localStorage.removeItem('access_token')
router.push({ name: 'login' })
}
async function loginWithTelegram(data: TelegramLoginData) {
const { data: response } = await api.post<AuthResponse>('/auth/telegram-login', data)
setAuth(response)
router.push({ name: 'dashboard' })
}
async function loginWithWebApp(initData: string) {
const { data: response } = await api.post<AuthResponse>('/auth/telegram-webapp', { init_data: initData })
setAuth(response)
router.push({ name: 'dashboard' })
}
return { token, rider, isAuthenticated, loginWithTelegram, loginWithWebApp, logout }
})

View File

@@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { Rider } from '../types/models'
export const useRiderStore = defineStore('rider', () => {
const { api } = useApi()
const rider = ref<Rider | null>(null)
async function fetchRider(id: string) {
const { data } = await api.get<Rider>(`/rider/profile/${id}`)
rider.value = data
}
async function updateRider(id: string, updates: Partial<Rider>) {
const { data } = await api.put<Rider>(`/rider/profile/${id}`, updates)
rider.value = data
}
return { rider, fetchRider, updateRider }
})

3
frontend/src/style.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@plugin "tailwindcss-primeui";
@import "primeicons/primeicons.css";

View File

@@ -0,0 +1,50 @@
export interface Rider {
id: string
telegram_id: number | null
telegram_username: string | null
avatar_url: string | null
name: string
ftp: number | null
lthr: number | null
weight: number | null
zones_config: Record<string, unknown> | null
goals: string | null
experience_level: string | null
}
export interface ActivityMetrics {
tss: number | null
normalized_power: number | null
intensity_factor: number | null
variability_index: number | null
avg_power: number | null
max_power: number | null
avg_hr: number | null
max_hr: number | null
avg_cadence: number | null
avg_speed: number | null
}
export interface Activity {
id: string
rider_id: string
name: string | null
activity_type: string
date: string
duration: number
distance: number | null
elevation_gain: number | null
metrics: ActivityMetrics | null
}
export interface DataPoint {
timestamp: string
power: number | null
heart_rate: number | null
cadence: number | null
speed: number | null
latitude: number | null
longitude: number | null
altitude: number | null
temperature: number | null
}

31
frontend/src/types/telegram.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
interface TelegramWebApp {
initData: string
initDataUnsafe: {
user?: {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
}
}
ready(): void
close(): void
expand(): void
}
interface Window {
Telegram?: {
WebApp?: TelegramWebApp
}
}
interface TelegramLoginData {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
auth_date: number
hash: string
}

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import Card from 'primevue/card'
import Button from 'primevue/button'
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Activities</h1>
<Button label="Upload .FIT" icon="pi pi-upload" />
</div>
<Card>
<template #content>
<p class="text-surface-400">Activity list with filters coming soon</p>
</template>
</Card>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import Card from 'primevue/card'
const route = useRoute()
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Activity Detail</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<template #title>Power / HR Chart</template>
<template #content>
<p class="text-surface-400">ECharts coming soon</p>
</template>
</Card>
<Card>
<template #title>Route Map</template>
<template #content>
<p class="text-surface-400">Leaflet map coming soon</p>
</template>
</Card>
<Card>
<template #title>Metrics</template>
<template #content>
<p class="text-surface-400">NP, IF, TSS, zones coming soon</p>
</template>
</Card>
<Card>
<template #title>Intervals</template>
<template #content>
<p class="text-surface-400">Detected intervals table coming soon</p>
</template>
</Card>
</div>
<p class="text-surface-500 text-sm mt-4">ID: {{ route.params.id }}</p>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import Card from 'primevue/card'
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<template #title>Current Form</template>
<template #content>
<p class="text-surface-400">CTL / ATL / TSB coming soon</p>
</template>
</Card>
<Card>
<template #title>Weekly Load</template>
<template #content>
<p class="text-surface-400">Weekly TSS summary coming soon</p>
</template>
</Card>
<Card>
<template #title>Recent Rides</template>
<template #content>
<p class="text-surface-400">Last 5 activities coming soon</p>
</template>
</Card>
</div>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
// Telegram Login Widget callback — must be global
;(window as any).onTelegramAuth = async (user: TelegramLoginData) => {
loading.value = true
error.value = ''
try {
await auth.loginWithTelegram(user)
} catch (e: any) {
error.value = e.response?.data?.detail || 'Authorization failed'
loading.value = false
}
}
onMounted(async () => {
// Check if running inside Telegram WebApp
const webapp = window.Telegram?.WebApp
if (webapp?.initData) {
loading.value = true
webapp.ready()
webapp.expand()
try {
await auth.loginWithWebApp(webapp.initData)
} catch (e: any) {
error.value = e.response?.data?.detail || 'WebApp authorization failed'
loading.value = false
}
return
}
// Render Telegram Login Widget for browser users
const container = document.getElementById('telegram-login-container')
if (container) {
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '')
script.setAttribute('data-size', 'large')
script.setAttribute('data-radius', '8')
script.setAttribute('data-onauth', 'onTelegramAuth(user)')
script.setAttribute('data-request-access', 'write')
script.async = true
container.appendChild(script)
}
})
</script>
<template>
<div class="flex items-center justify-center min-h-[80vh]">
<Card class="w-full max-w-md">
<template #title>
<div class="text-center">
<span class="text-primary text-3xl font-bold">VeloBrain</span>
<p class="text-surface-400 text-sm mt-2">AI-Powered Cycling Training Platform</p>
</div>
</template>
<template #content>
<div class="flex flex-col items-center gap-4">
<div v-if="loading" class="flex flex-col items-center gap-3">
<ProgressSpinner style="width: 50px; height: 50px" />
<p class="text-surface-400 text-sm">Signing in via Telegram...</p>
</div>
<div v-else>
<p v-if="error" class="text-red-400 text-sm mb-4 text-center">{{ error }}</p>
<div id="telegram-login-container" class="flex justify-center"></div>
</div>
</div>
</template>
</Card>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import Card from 'primevue/card'
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<Card>
<template #content>
<p class="text-surface-400">FTP, weight, zones, goals coming soon</p>
</template>
</Card>
</div>
</template>

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

BIN
velobrain_architecture.docx Normal file

Binary file not shown.