init
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal 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
23
.env.example
Normal 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
40
.gitignore
vendored
Normal 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
36
backend/alembic.ini
Normal 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
58
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
102
backend/app/api/activities.py
Normal file
102
backend/app/api/activities.py
Normal 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
117
backend/app/api/auth.py
Normal 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
52
backend/app/api/rider.py
Normal 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
11
backend/app/api/router.py
Normal 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"])
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
36
backend/app/core/auth.py
Normal file
36
backend/app/core/auth.py
Normal 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
|
||||
34
backend/app/core/config.py
Normal file
34
backend/app/core/config.py
Normal 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()
|
||||
17
backend/app/core/database.py
Normal file
17
backend/app/core/database.py
Normal 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
|
||||
71
backend/app/core/security.py
Normal file
71
backend/app/core/security.py
Normal 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
37
backend/app/main.py
Normal 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"}
|
||||
16
backend/app/models/__init__.py
Normal file
16
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
84
backend/app/models/activity.py
Normal file
84
backend/app/models/activity.py
Normal 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")
|
||||
42
backend/app/models/fitness.py
Normal file
42
backend/app/models/fitness.py
Normal 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())
|
||||
29
backend/app/models/rider.py
Normal file
29
backend/app/models/rider.py
Normal 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")
|
||||
23
backend/app/models/training.py
Normal file
23
backend/app/models/training.py
Normal 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())
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
52
backend/app/schemas/activity.py
Normal file
52
backend/app/schemas/activity.py
Normal 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
|
||||
23
backend/app/schemas/auth.py
Normal file
23
backend/app/schemas/auth.py
Normal 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
|
||||
38
backend/app/schemas/rider.py
Normal file
38
backend/app/schemas/rider.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
102
backend/app/services/fit_parser.py
Normal file
102
backend/app/services/fit_parser.py
Normal 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)
|
||||
126
backend/app/services/gemini_client.py
Normal file
126
backend/app/services/gemini_client.py
Normal 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
|
||||
83
backend/app/services/metrics.py
Normal file
83
backend/app/services/metrics.py
Normal 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
34
backend/requirements.txt
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
5
frontend/README.md
Normal 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
14
frontend/index.html
Normal 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
2490
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
31
frontend/src/App.vue
Normal 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>
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
28
frontend/src/composables/useApi.ts
Normal file
28
frontend/src/composables/useApi.ts
Normal 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
26
frontend/src/main.ts
Normal 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
52
frontend/src/router.ts
Normal 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
|
||||
32
frontend/src/stores/activities.ts
Normal file
32
frontend/src/stores/activities.ts
Normal 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 }
|
||||
})
|
||||
45
frontend/src/stores/auth.ts
Normal file
45
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
21
frontend/src/stores/rider.ts
Normal file
21
frontend/src/stores/rider.ts
Normal 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
3
frontend/src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-primeui";
|
||||
@import "primeicons/primeicons.css";
|
||||
50
frontend/src/types/models.ts
Normal file
50
frontend/src/types/models.ts
Normal 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
31
frontend/src/types/telegram.d.ts
vendored
Normal 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
|
||||
}
|
||||
18
frontend/src/views/ActivitiesView.vue
Normal file
18
frontend/src/views/ActivitiesView.vue
Normal 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>
|
||||
39
frontend/src/views/ActivityDetailView.vue
Normal file
39
frontend/src/views/ActivityDetailView.vue
Normal 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>
|
||||
29
frontend/src/views/DashboardView.vue
Normal file
29
frontend/src/views/DashboardView.vue
Normal 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>
|
||||
78
frontend/src/views/LoginView.vue
Normal file
78
frontend/src/views/LoginView.vue
Normal 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>
|
||||
14
frontend/src/views/SettingsView.vue
Normal file
14
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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
BIN
velobrain_architecture.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user