Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
70 KiB
LifeTrack — Architecture technique (v1)
Document de conception destiné aux agents d'implémentation. Il est exhaustif et normatif : toute décision non couverte ici doit respecter la section 10 (CONVENTIONS), qui sera extraite telle quelle dans
CONVENTIONS.md.Langue : prose et UI en français, identifiants de code, noms de fichiers, routes API et commentaires en anglais.
1. Vue d'ensemble
LifeTrack est une application web auto-hébergée de suivi de vie personnelle, déployée à la maison
via Docker Compose (Windows ou Linux). Un seul utilisateur principal, mais le schéma de données est
multi-utilisateur dès la v1 (toutes les tables métier portent user_id).
Stack imposée (non négociable) :
| Couche | Technologie |
|---|---|
| API | Python 3.12, FastAPI, SQLAlchemy 2.0 (typé), Pydantic v2 |
| Base | PostgreSQL 16 (driver psycopg v3) |
| Frontend | React 18, TypeScript, Vite, TailwindCSS, Apache ECharts, TanStack React Query v5, React Router v6 |
| Auth | JWT (access token) + clés API d'appareil ; hachage argon2 (pwdlib) |
| Déploiement | Docker Compose : postgres + api (uvicorn) + web (nginx, sert le build Vite et proxifie /api) |
Modules métier v1 : health (santé/fitness — module principal), vape (sevrage tabagique),
finance (finances personnelles), plus les modules transverses auth et imports.
Principes structurants :
- Modularité par auto-découverte — backend : chaque module sous
app/modules/<name>/est découvert par itérationpkgutil; frontend : chaque module soussrc/modules/<name>/est découvert parimport.meta.glob. Aucun développeur de module ne modifie jamais un fichier partagé (main.py,router.tsx,Sidebar.tsx…) : il suffit de créer le dossier du module en respectant le contrat. - Framework de connecteurs/importeurs — les sources de données (CSV Foodvisor, OFX bancaire,
exports PayPal, poussées JSON depuis un pont Android Health Connect…) sont branchées via un
registre d'importeurs (
BaseImporter) et de handlers d'ingestion (BaseIngestHandler). Ajouter une source = ajouter une classe, rien d'autre. - Temps — stockage en UTC (colonnes
TIMESTAMPTZ), affichage et agrégations journalières dans le fuseauEurope/Paris(paramétrable via?tz=). Les totaux « par jour » fournis par les sources (pas de timestamp) sont stockés dans des colonnesDATEreprésentant le jour local. - v1 pragmatique — création des tables via
Base.metadata.create_all()au démarrage (Alembic sera introduit plus tard) ; imports de fichiers traités de façon synchrone dans la requête HTTP (volumes personnels faibles) ; pas de refresh token (access token de 7 jours).
2. Arborescence du monorepo
LifeTrack/
├── README.md
├── CONVENTIONS.md # extrait verbatim de la section 10 de ce document
├── .gitignore
├── .env.example # variables d'environnement (voir §9.5)
├── docker-compose.yml # production (postgres + api + web)
├── docker-compose.dev.yml # surcouche développement (hot reload)
├── docker/
│ ├── api.Dockerfile # build multi-étapes Python
│ ├── web.Dockerfile # build Vite -> nginx
│ └── nginx.conf # sert le SPA + proxy /api -> api:8000
├── docs/
│ └── design/
│ └── architecture.md # ce document
├── apps/
│ ├── api/
│ │ ├── requirements.txt
│ │ ├── requirements-dev.txt
│ │ ├── pytest.ini
│ │ └── app/
│ │ ├── __init__.py
│ │ ├── main.py # fabrique d'application + lifespan
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── config.py # Settings (pydantic-settings)
│ │ │ ├── database.py # engine, SessionLocal, Base, get_db
│ │ │ ├── mixins.py # TimestampMixin, SourceMixin (dédup)
│ │ │ ├── security.py # hash mots de passe, JWT, clés API
│ │ │ ├── dependencies.py # get_current_user, require_ingest_scope…
│ │ │ ├── errors.py # AppError + handlers (forme d'erreur)
│ │ │ ├── pagination.py # PageParams, Page[T], paginate()
│ │ │ ├── timeutils.py # utcnow(), local_day(), parse_tz()
│ │ │ ├── module_loader.py # AUTO-DÉCOUVERTE pkgutil (routers, models…)
│ │ │ ├── importing/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── base.py # BaseImporter, NormalizedRecord, UpsertOutcome
│ │ │ │ ├── registry.py # IMPORTER_REGISTRY, @register_importer
│ │ │ │ └── hashing.py # content_hash() canonique
│ │ │ └── ingest/
│ │ │ ├── __init__.py
│ │ │ ├── base.py # BaseIngestHandler, IngestRecord
│ │ │ └── registry.py # INGEST_REGISTRY, @register_ingest_handler
│ │ ├── modules/
│ │ │ ├── __init__.py
│ │ │ ├── auth/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── router.py # /api/auth/* (setup, login, me, device-keys)
│ │ │ │ ├── models.py # User, DeviceApiKey
│ │ │ │ ├── schemas.py
│ │ │ │ └── service.py
│ │ │ ├── health/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── router.py # /api/health/*
│ │ │ │ ├── models.py # HealthProfile, WeightEntry, BodyMeasurement,
│ │ │ │ │ # DailyActivity, Workout, FoodEntry
│ │ │ │ ├── schemas.py
│ │ │ │ ├── service.py
│ │ │ │ ├── calculations.py # BMR/TDEE, balance énergétique, projections
│ │ │ │ ├── importers.py # FoodvisorCsvImporter, HealthConnectCsv…
│ │ │ │ └── ingest.py # HealthIngestHandler (pont Android)
│ │ │ ├── vape/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── router.py # /api/vape/*
│ │ │ │ ├── models.py # VapeCostModel, VapeDailyLog, CoilChange,
│ │ │ │ │ # SmokingBaseline
│ │ │ │ ├── schemas.py
│ │ │ │ ├── service.py
│ │ │ │ ├── calculations.py # coût/ml, coût/jour, économies cumulées
│ │ │ │ └── ingest.py # VapeIngestHandler (optionnel)
│ │ │ ├── finance/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── router.py # /api/finance/*
│ │ │ │ ├── models.py # Account, Transaction, Category,
│ │ │ │ │ # CategoryRule, Budget
│ │ │ │ ├── schemas.py
│ │ │ │ ├── service.py
│ │ │ │ ├── categorize.py # moteur de règles + détection récurrences
│ │ │ │ └── importers.py # BankGenericCsv, OfxImporter, PaypalCsv
│ │ │ └── imports/
│ │ │ ├── __init__.py
│ │ │ ├── router.py # /api/imports/* ET /api/ingest/{domain}
│ │ │ ├── models.py # ImportRun
│ │ │ ├── schemas.py
│ │ │ └── service.py # orchestration d'un import (run_import)
│ │ └── tests/
│ │ ├── conftest.py # app de test + SQLite/postgres éphémère
│ │ ├── test_auth.py
│ │ ├── test_module_loader.py
│ │ ├── test_imports.py
│ │ └── modules/… # tests par module
│ └── web/
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ ├── postcss.config.js
│ └── src/
│ ├── main.tsx # bootstrap React + QueryClientProvider
│ ├── styles/
│ │ └── index.css # @tailwind + variables de thème sombre
│ ├── types/
│ │ ├── module.ts # ModuleManifest, NavItem (contrat frontend)
│ │ └── api.ts # Page<T>, ApiErrorShape
│ ├── lib/
│ │ ├── api.ts # wrapper fetch (baseURL /api, JWT, erreurs)
│ │ ├── queryClient.ts # instance TanStack Query
│ │ ├── dates.ts # helpers ISO <-> affichage fr-FR
│ │ └── format.ts # nombres, €, kg, kcal en fr-FR
│ ├── components/
│ │ ├── ui/ # Button, Card, Input, Select, Modal, Table,
│ │ │ │ # Badge, Spinner, EmptyState, PageHeader,
│ │ │ └── … # StatCard, Tabs, DateRangePicker
│ │ └── charts/
│ │ ├── EChart.tsx # wrapper ECharts (resize, dispose, thème)
│ │ └── theme.ts # thème sombre ECharts partagé
│ ├── app/
│ │ ├── App.tsx
│ │ ├── router.tsx # assemble layout + routes des modules
│ │ ├── modules.ts # AUTO-DÉCOUVERTE import.meta.glob
│ │ ├── layout/
│ │ │ ├── AppLayout.tsx # sidebar + topbar + <Outlet/>
│ │ │ ├── Sidebar.tsx # nav générée depuis les manifestes
│ │ │ └── Topbar.tsx
│ │ ├── auth/
│ │ │ ├── AuthContext.tsx # token, user, login/logout
│ │ │ └── ProtectedRoute.tsx # redirige /login ou /setup
│ │ └── pages/
│ │ ├── LoginPage.tsx # hors modules (coquille applicative)
│ │ ├── SetupPage.tsx # assistant premier démarrage
│ │ └── NotFoundPage.tsx
│ └── modules/
│ ├── home/
│ │ ├── index.ts # manifeste (ordre 0, path "/")
│ │ ├── strings.ts
│ │ ├── api.ts
│ │ └── pages/HomePage.tsx # tableau de bord global (widgets)
│ ├── health/
│ │ ├── index.ts
│ │ ├── strings.ts
│ │ ├── api.ts # hooks React Query du module
│ │ ├── pages/
│ │ │ ├── HealthDashboardPage.tsx
│ │ │ ├── WeightPage.tsx
│ │ │ ├── MeasurementsPage.tsx
│ │ │ ├── ActivityPage.tsx
│ │ │ ├── WorkoutsPage.tsx
│ │ │ └── NutritionPage.tsx
│ │ └── components/ # widgets propres au module
│ ├── vape/
│ │ ├── index.ts, strings.ts, api.ts
│ │ └── pages/ (VapeDashboardPage, VapeLogPage, CoilsPage,
│ │ CostModelPage, SavingsPage)
│ ├── finance/
│ │ ├── index.ts, strings.ts, api.ts
│ │ └── pages/ (FinanceDashboardPage, TransactionsPage,
│ │ CategoriesPage, BudgetsPage, RecurringPage)
│ ├── imports/
│ │ ├── index.ts, strings.ts, api.ts
│ │ └── pages/ImportsPage.tsx # upload + historique des ImportRun
│ └── settings/
│ ├── index.ts, strings.ts, api.ts
│ └── pages/ (ProfilePage, DeviceKeysPage)
3. Backend — structure FastAPI
3.1 Fabrique d'application et cycle de vie
apps/api/app/main.py :
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import get_settings
from app.core.database import Base, engine
from app.core.errors import register_error_handlers
from app.core.module_loader import import_side_modules, register_routers
@asynccontextmanager
async def lifespan(app: FastAPI):
# Import every module's models/importers/ingest so that:
# - Base.metadata knows all tables before create_all
# - importer & ingest registries are populated (decorator side effects)
import_side_modules()
# v1 table-creation strategy: create_all on startup (Alembic later).
Base.metadata.create_all(bind=engine)
yield
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="LifeTrack API",
version="1.0.0",
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
register_error_handlers(app)
register_routers(app) # auto-discovery, see §3.4
@app.get("/api/healthz", include_in_schema=False)
def healthz() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()
Lancement dev : uvicorn app.main:app --reload --port 8000 (cwd apps/api).
3.2 Configuration — app/core/config.py (pydantic-settings)
Toutes les variables d'environnement sont préfixées LIFETRACK_. Un fichier .env est lu en dev.
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="LIFETRACK_", env_file=".env", extra="ignore"
)
# Database (psycopg v3 driver)
database_url: str = "postgresql+psycopg://lifetrack:lifetrack@localhost:5432/lifetrack"
# Auth
jwt_secret: str = "change-me-in-env" # MUST be overridden in production
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days (home deployment)
# Time
timezone: str = "Europe/Paris" # default tz for daily aggregations
# Imports
max_upload_bytes: int = 20 * 1024 * 1024 # 20 MiB
# Dev only: Vite dev server origin(s), comma-separated env value
cors_origins: list[str] = []
@lru_cache
def get_settings() -> Settings:
return Settings()
Règle : jamais de Settings() instancié ailleurs — toujours get_settings().
3.3 Base de données et sessions — app/core/database.py
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
"""Single declarative base for the whole application."""
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
"""FastAPI dependency: one session per request, closed automatically."""
with SessionLocal() as session:
yield session
Style SQLAlchemy 2.0 typé obligatoire : Mapped[...] + mapped_column(...), requêtes via
select() (jamais session.query()).
Mixins partagés — app/core/mixins.py :
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
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()
)
class SourceMixin:
"""Provenance + dedupe columns for every imported/ingested row.
source: "manual" | importer id ("foodvisor_csv") | ingest source ("android_bridge")
external_id: stable id from the source system, if any
content_hash: sha256 of canonical payload, used when no external_id exists
"""
source: Mapped[str] = mapped_column(String(50), default="manual")
external_id: Mapped[str | None] = mapped_column(String(255), default=None)
content_hash: Mapped[str | None] = mapped_column(String(64), default=None)
Chaque table utilisant SourceMixin déclare dans __table_args__ :
UniqueConstraint("user_id", "source", "external_id", name="uq_<table>_external"),
UniqueConstraint("user_id", "content_hash", name="uq_<table>_hash"),
(PostgreSQL autorise plusieurs NULL dans un index unique — les saisies manuelles sans
external_id/content_hash ne sont donc pas bloquées.)
3.4 Auto-découverte des modules — app/core/module_loader.py
Contrat : un module backend est un paquet app/modules/<name>/ contenant au minimum
__init__.py et router.py qui expose une variable router: APIRouter. Fichiers optionnels
importés automatiquement s'ils existent : models.py, importers.py, ingest.py.
Personne ne touche main.py.
import importlib
import pkgutil
from fastapi import APIRouter, FastAPI
from app import modules as modules_pkg
# Optional side-effect files imported for every module (models -> metadata,
# importers/ingest -> registries populated by decorators).
_SIDE_FILES = ("models", "importers", "ingest")
def iter_module_names() -> list[str]:
return sorted(
info.name
for info in pkgutil.iter_modules(modules_pkg.__path__)
if not info.name.startswith("_")
)
def import_side_modules() -> None:
for name in iter_module_names():
for side in _SIDE_FILES:
dotted = f"app.modules.{name}.{side}"
try:
importlib.import_module(dotted)
except ModuleNotFoundError as exc:
# Only swallow "file does not exist"; re-raise real import errors
# coming from inside the file (missing dependency, typo…).
if exc.name != dotted:
raise
def register_routers(app: FastAPI) -> None:
for name in iter_module_names():
mod = importlib.import_module(f"app.modules.{name}.router")
router = getattr(mod, "router", None)
if not isinstance(router, APIRouter):
raise RuntimeError(
f"Module '{name}' must expose an APIRouter named 'router' in router.py"
)
app.include_router(router, prefix="/api")
Dans chaque router.py :
router = APIRouter(prefix="/health", tags=["health"])
Le préfixe standard est /{nom_du_module}. Cas particulier autorisé : un router racine sans
préfixe qui agrège plusieurs sous-routeurs (utilisé par imports pour exposer à la fois
/api/imports/* et /api/ingest/*, voir §5.5).
3.5 Gestion des erreurs — app/core/errors.py
Hiérarchie d'exceptions applicatives + handlers globaux garantissant une forme d'erreur unique
(voir §8.3). Les messages message sont en français (affichés tels quels par le frontend).
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code: int = 400
code: str = "bad_request"
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
self.message = message
self.details = details or {}
super().__init__(message)
class NotFoundError(AppError):
status_code, code = 404, "not_found"
class ConflictError(AppError):
status_code, code = 409, "conflict"
class UnauthorizedError(AppError):
status_code, code = 401, "unauthorized"
class ForbiddenError(AppError):
status_code, code = 403, "forbidden"
class DomainValidationError(AppError):
status_code, code = 422, "validation_error"
def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]:
return {"error": {"code": code, "message": message, "details": details or {}}}
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=_payload(exc.code, exc.message, exc.details),
)
@app.exception_handler(RequestValidationError)
async def validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content=_payload(
"validation_error",
"Les données envoyées sont invalides.",
{"errors": exc.errors()},
),
)
Règle : le code de service lève NotFoundError("Relevé de poids introuvable.") etc. — jamais
HTTPException directement dans les modules.
3.6 Pagination — app/core/pagination.py
from typing import Generic, TypeVar
from fastapi import Query
from pydantic import BaseModel
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
T = TypeVar("T")
class PageParams:
def __init__(
self,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
self.page = page
self.page_size = page_size
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
def paginate(db: Session, stmt: Select, params: PageParams) -> tuple[list, int]:
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
rows = db.scalars(stmt.offset(params.offset).limit(params.page_size)).all()
return list(rows), total
Usage dans un routeur :
@router.get("/weights", response_model=Page[WeightRead])
def list_weights(
params: Annotated[PageParams, Depends()],
db: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
) -> Page[WeightRead]:
stmt = (
select(WeightEntry)
.where(WeightEntry.user_id == user.id)
.order_by(WeightEntry.measured_at.desc())
)
items, total = paginate(db, stmt, params)
return Page(items=items, total=total, page=params.page, page_size=params.page_size)
3.7 Dates et fuseaux — app/core/timeutils.py
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
from app.core.config import get_settings
from app.core.errors import DomainValidationError
def utcnow() -> datetime:
return datetime.now(UTC)
def resolve_tz(tz: str | None) -> ZoneInfo:
name = tz or get_settings().timezone
try:
return ZoneInfo(name)
except Exception as exc:
raise DomainValidationError(f"Fuseau horaire inconnu : {name}") from exc
def local_day(dt_utc: datetime, tz: ZoneInfo) -> date:
return dt_utc.astimezone(tz).date()
Agrégation « par jour » côté SQL (préférée pour les gros volumes) :
day = func.date(func.timezone(tz_name, Model.measured_at)) # TIMESTAMPTZ -> local date
4. Authentification et sécurité
4.1 Modèles — app/modules/auth/models.py
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(default=True)
class DeviceApiKey(TimestampMixin, Base):
"""Long-lived scoped token for device bridges (Android Health Connect…)."""
__tablename__ = "device_api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String(100)) # ex: "Pixel 8 – pont Health Connect"
key_prefix: Mapped[str] = mapped_column(String(12), unique=True, index=True)
key_hash: Mapped[str] = mapped_column(String(64)) # sha256 hex of the full key
scopes: Mapped[list[str]] = mapped_column(JSON, default=list) # ["ingest:health"]
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
4.2 Primitives — app/core/security.py
- Hachage mots de passe :
pwdlibavec recommandation argon2 —password_hasher = PasswordHash.recommended();hash(pw)/verify(pw, hash). - JWT : bibliothèque
PyJWT. Claims :sub(str(user.id)),iat,exp. Signé HS256 avecsettings.jwt_secret. Fonctionscreate_access_token(user_id) -> stretdecode_access_token(token) -> int(lèveUnauthorizedErrorsi invalide/expiré). - Clés API d'appareil : format
ltk_<prefix>_<secret>oùprefix= 8 hex aléatoires,secret=secrets.token_urlsafe(32). On stockekey_prefix+sha256(full_key). La clé en clair n'est retournée qu'une seule fois à la création. Fonctionsgenerate_device_key() -> tuple[full_key, prefix, key_hash]etverify_device_key(db, full_key) -> DeviceApiKey(lookup par prefix, comparaisonhmac.compare_digest, vérifierevoked_at is None, met à jourlast_used_at).
4.3 Dépendances — app/core/dependencies.py
bearer = HTTPBearer(auto_error=False)
def get_current_user(...) -> User:
"""JWT Bearer -> User. Raises UnauthorizedError (401) otherwise."""
def get_ingest_identity(required_scope: str) -> Callable[..., User]:
"""Factory dependency for ingest endpoints.
Accepts EITHER:
- Authorization: Bearer <jwt> (interactive user), OR
- X-API-Key: ltk_... (device key)
A device key must hold `required_scope` (ex: "ingest:health"),
or the wildcard "ingest:*". Raises 401/403 accordingly.
"""
Les scopes de clé sont des chaînes ingest:<domain> (ingest:health, ingest:vape,
ingest:finance) ou ingest:*.
4.4 Endpoints du module auth (préfixe /api/auth)
| Méthode & chemin | Auth | Description |
|---|---|---|
GET /auth/status |
publique | {"setup_required": bool} — vrai si users est vide. Le frontend redirige vers l'assistant /setup. |
POST /auth/setup |
publique | Corps {email, password, display_name}. Crée le premier utilisateur uniquement si aucun n'existe (sinon 409 conflict). Retourne {access_token, user}. |
POST /auth/login |
publique | Corps JSON {email, password}. Retourne {access_token, token_type: "bearer", user}. Échec : 401 message français générique. |
GET /auth/me |
JWT | Profil de l'utilisateur courant. |
PATCH /auth/me |
JWT | Modifier display_name / email / mot de passe (current_password requis). |
GET /auth/device-keys |
JWT | Liste (sans secret) : id, name, key_prefix, scopes, last_used_at, created_at, revoked_at. |
POST /auth/device-keys |
JWT | Corps {name, scopes}. Retourne {key: "ltk_..."} une seule fois + métadonnées. |
DELETE /auth/device-keys/{id} |
JWT | Révocation (met revoked_at, ne supprime pas la ligne). |
5. Framework de connecteurs / importeurs
Deux voies d'entrée normalisées :
- Fichiers (CSV/OFX/exports d'apps) →
BaseImporter, upload viaPOST /api/imports. - Poussées JSON (pont Android, scripts) →
BaseIngestHandler,POST /api/ingest/{domain}.
Les deux convergent vers les mêmes tables métier et la même stratégie de déduplication.
5.1 Types partagés — app/core/importing/base.py
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, ClassVar
from sqlalchemy.orm import Session
class UpsertOutcome(StrEnum):
INSERTED = "inserted"
UPDATED = "updated" # daily aggregates replaced in place
DUPLICATE = "duplicate"
ERROR = "error"
@dataclass
class NormalizedRecord:
kind: str # ex: "weight", "food_entry", "transaction"
data: dict[str, Any] # normalized fields (canonical units, see CONVENTIONS)
external_id: str | None = None # stable source id if available
# Fields of `data` used to build content_hash when external_id is None.
dedupe_fields: tuple[str, ...] = field(default_factory=tuple)
class BaseImporter(ABC):
"""One importer = one (source, file format) pair. Stateless; instantiated per run."""
id: ClassVar[str] # unique snake_case, ex: "foodvisor_csv"
label: ClassVar[str] # French label shown in UI, ex: "Foodvisor (export CSV)"
domain: ClassVar[str] # "health" | "vape" | "finance"
accepted_extensions: ClassVar[tuple[str, ...]] # (".csv",), (".ofx",)…
@classmethod
@abstractmethod
def sniff(cls, filename: str, head: bytes) -> bool:
"""Return True if this importer recognizes the file (used when source='auto').
`head` = first 4096 bytes. Must be cheap and never raise."""
@abstractmethod
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
"""Decode bytes (handle encodings utf-8/cp1252 and csv dialects) and yield
normalized records. Raise ImporterParseError for a fatally malformed file;
yield-level row errors should raise RowError inside iteration."""
@abstractmethod
def upsert(self, db: Session, user_id: int, record: NormalizedRecord) -> UpsertOutcome:
"""Write one record into the module's tables, honoring dedupe rules."""
hashing.py :
import hashlib, json
def content_hash(record: NormalizedRecord) -> str:
subset = {k: record.data.get(k) for k in sorted(record.dedupe_fields)}
payload = json.dumps(subset, sort_keys=True, default=str, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
Stratégie de déduplication (normative) :
- Enregistrements « événement » (pesée, transaction, repas, entraînement) :
- si
external_idfourni par la source → contrainte unique(user_id, source, external_id); en collision →DUPLICATE(skip). - sinon →
content_hashsurdedupe_fields→ contrainte(user_id, content_hash); en collision →DUPLICATE.
- si
- Enregistrements « agrégat journalier » (
DailyActivity,VapeDailyLog) : clé naturelle(user_id, day, source)avec upsert-remplacement (INSERT … ON CONFLICT DO UPDATE, dialectsqlalchemy.dialects.postgresql.insert) →UPDATEDsi la ligne existait.
5.2 Registre — app/core/importing/registry.py
IMPORTER_REGISTRY: dict[str, type[BaseImporter]] = {}
def register_importer(cls: type[BaseImporter]) -> type[BaseImporter]:
if cls.id in IMPORTER_REGISTRY:
raise RuntimeError(f"Duplicate importer id: {cls.id}")
IMPORTER_REGISTRY[cls.id] = cls
return cls
def detect_importer(filename: str, head: bytes) -> type[BaseImporter] | None:
matches = [c for c in IMPORTER_REGISTRY.values() if c.sniff(filename, head)]
return matches[0] if len(matches) == 1 else None # ambiguous -> force explicit choice
Chaque module déclare ses importeurs dans son fichier importers.py avec le décorateur
@register_importer — le chargeur (§3.4) importe ces fichiers au démarrage, le registre se
remplit tout seul.
5.3 Suivi des imports — app/modules/imports/models.py
class ImportRun(TimestampMixin, Base):
__tablename__ = "import_runs"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
importer_id: Mapped[str] = mapped_column(String(50)) # ex: "foodvisor_csv"
domain: Mapped[str] = mapped_column(String(20)) # "health" | "vape" | "finance"
filename: Mapped[str] = mapped_column(String(255))
file_size: Mapped[int] = mapped_column()
status: Mapped[str] = mapped_column(String(20)) # "completed" | "failed"
rows_total: Mapped[int] = mapped_column(default=0)
rows_inserted: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
rows_duplicates: Mapped[int] = mapped_column(default=0)
rows_errors: Mapped[int] = mapped_column(default=0)
error_details: Mapped[list[dict]] = mapped_column(JSON, default=list) # [{row, message}] capped at 100
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
service.py::run_import(db, user, importer_id, filename, data) -> ImportRun orchestre :
création du run → boucle parse() → upsert() par ligne (erreur de ligne = compteur+détail, ne
stoppe pas le run) → commit unique en fin de run → statut completed (ou failed si
ImporterParseError). Synchrone en v1 (fichiers personnels, quelques milliers de lignes).
5.4 Endpoints du module imports (préfixe /api/imports)
| Méthode & chemin | Auth | Description |
|---|---|---|
GET /imports/sources |
JWT | Importeurs disponibles : [{id, label, domain, accepted_extensions}] — alimente le sélecteur de « profil de source » du frontend. |
POST /imports |
JWT | multipart/form-data : champ file + champ source (= importer_id, ou "auto" pour sniffer). Refus 413 si > max_upload_bytes, 422 si source inconnue ou sniff ambigu (message : « Format non reconnu, choisissez un profil de source. »). Retourne l'ImportRun complet. |
GET /imports |
JWT | Historique paginé, tri -started_at, filtre ?domain=. |
GET /imports/{id} |
JWT | Détail d'un run (dont error_details). |
5.5 Ingestion JSON générique — POST /api/ingest/{domain}
Contrat côté core — app/core/ingest/base.py :
@dataclass
class IngestRecord:
type: str # ex: "steps", "weight", "workout"
data: dict[str, Any]
external_id: str | None = None
class BaseIngestHandler(ABC):
domain: ClassVar[str] # "health"
record_types: ClassVar[tuple[str, ...]] # accepted `type` values
@abstractmethod
def apply(self, db: Session, user_id: int, record: IngestRecord) -> UpsertOutcome:
...
Registre INGEST_REGISTRY: dict[str, BaseIngestHandler] + décorateur
@register_ingest_handler (mêmes règles que les importeurs ; déclaré dans ingest.py du module).
Le routeur du module imports expose (agrégation de deux sous-routeurs, cas particulier
du contrat §3.4) :
router = APIRouter()
router.include_router(imports_router, prefix="/imports", tags=["imports"])
router.include_router(ingest_router, prefix="/ingest", tags=["ingest"])
Requête (POST /api/ingest/health, en-tête X-API-Key: ltk_... ou JWT) :
{
"source": "android_bridge",
"records": [
{"type": "steps", "external_id": "hc:2026-08-12", "data": {"day": "2026-08-12", "steps": 9421, "calories_kcal": 2350, "distance_m": 6800}},
{"type": "weight", "external_id": "hc:w:1755012345", "data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 91.4}}
]
}
Réponse 200 :
{
"domain": "health",
"received": 2,
"inserted": 1,
"updated": 1,
"duplicates": 0,
"errors": []
}
Chaque enregistrement est traité indépendamment ; une erreur unitaire est reportée dans
errors: [{index, type, message}] sans faire échouer le lot. Domaine inconnu → 404,
type non supporté → erreur unitaire. Limite : 1 000 enregistrements par requête (422 au-delà).
5.6 Importeurs v1 à implémenter
| id | domaine | format | Notes de parsing |
|---|---|---|---|
foodvisor_csv |
health | CSV | Export Foodvisor : lignes repas/aliments → FoodEntry (kcal, macros). Sniff : en-têtes caractéristiques Foodvisor. |
health_connect_csv |
health | CSV | Export CSV générique du pont Health Connect (pas/jour, poids, calories) — mêmes types que l'ingestion JSON. |
fitshow_csv |
health | CSV | Séances tapis exportées de FitShow → Workout (sport="treadmill", durée, distance, kcal). |
weight_generic_csv |
health | CSV | Deux colonnes date;poids (saisie historique de l'utilisateur). |
bank_generic_csv |
finance | CSV | Relevés banques françaises : séparateur ;, décimale virgule, encodage cp1252/utf-8 (détection BOM puis fallback), colonnes date/libellé/débit/crédit ou montant signé. Mapping colonnes tolérant (recherche d'en-têtes normalisés sans accents). |
ofx |
finance | OFX | Bibliothèque ofxparse ; external_id = FITID. |
paypal_csv |
finance | CSV | Export « Activité » PayPal ; external_id = code de transaction. |
Chaque importeur vit dans importers.py du module concerné. Le compte bancaire cible est déduit
du fichier quand c'est possible (OFX), sinon un compte "Compte importé" est créé par défaut ; le
frontend permettra de re-router plus tard (hors périmètre v1 : choix de compte dans l'upload via
champ optionnel account_id).
6. Modules métier — modèles et endpoints
Tous les modèles ci-dessous héritent de Base, TimestampMixin, et de SourceMixin quand la
donnée peut provenir d'un import (règle : toute table alimentée par importeur/ingestion porte
SourceMixin + les deux contraintes uniques du §3.3). Toutes les tables portent
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True).
6.1 health (préfixe /api/health)
Modèles (models.py) :
| Table | Colonnes clés |
|---|---|
health_profiles |
user_id (unique), height_cm: float, birth_date: date, sex: str ("male"/"female"), activity_level: str ("sedentary"/"light"/"moderate"/"active"/"very_active"), target_weight_kg: float, target_date: date | None, calorie_budget_override_kcal: int | None |
weight_entries |
measured_at: datetime(tz), weight_kg: float, body_fat_pct: float | None + SourceMixin |
body_measurements |
measured_at: datetime(tz), site: str ("waist","hips","chest","thigh","arm","neck","calf"), value_cm: float + SourceMixin |
daily_activities |
day: date (jour local), steps: int, calories_kcal: float, distance_m: float, active_minutes: int | None + SourceMixin ; unique (user_id, day, source) ; upsert-remplacement |
workouts |
started_at: datetime(tz), duration_s: int, sport: str ("treadmill","running","cycling","strength","other"…), distance_m: float | None, calories_kcal: float | None, avg_heart_rate: int | None, notes: str | None + SourceMixin |
food_entries |
eaten_at: datetime(tz), meal: str ("breakfast","lunch","dinner","snack"), label: str, quantity: str | None, calories_kcal: float, protein_g/carbs_g/fat_g: float | None + SourceMixin |
Calculs (calculations.py) — formules normatives :
- BMR (Mifflin-St Jeor) :
10*poids_kg + 6.25*taille_cm − 5*âge + (5 si homme, −161 si femme). - TDEE = BMR × facteur (
sedentary1.2,light1.375,moderate1.55,active1.725,very_active1.9). Le poids utilisé est la dernière pesée connue. - Balance énergétique du jour =
kcal ingérées (food_entries)− (BMR+calories activesoù calories actives = max(daily_activities.calories_kcal du jour toutes sources confondues — priorité à la source la plus complète :manual> pont > import) ; si la source fournit des calories totales brûlées, le pont doit envoyer le champcalories_kcalcomme actives — convention du connecteur). - Budget calorique quotidien =
TDEE − déficit_viséoùdéficit_visé = (poids_actuel − target_weight_kg) * 7700 / jours_restantssitarget_datedéfinie, plafonné à 1 000 kcal/j ; sinon défaut 500 kcal/j ;calorie_budget_override_kcalcourt-circuite tout. - Projection : à rythme = moyenne mobile 21 jours de la variation de poids, date estimée d'atteinte de l'objectif ; et courbe théorique via déficit moyen 14 jours / 7700 kcal par kg.
Endpoints :
| Méthode & chemin | Description |
|---|---|
GET/PUT /health/profile |
Profil santé (création à la première écriture). |
CRUD GET/POST /health/weights, PATCH/DELETE /health/weights/{id} |
Pesées ; liste paginée, filtres ?from=&to= (dates ISO). |
CRUD /health/measurements (+ ?site=) |
Mensurations. |
GET /health/activity?from=&to=&tz= |
Activité journalière fusionnée (une ligne/jour, meilleure source). |
POST /health/activity |
Saisie/écrasement manuel d'un jour. |
CRUD /health/workouts |
Entraînements. |
CRUD /health/food (+ ?day=&tz=) |
Journal alimentaire. |
GET /health/summary/daily?from=&to=&tz= |
Par jour : poids (dernière pesée ≤ jour), kcal in, kcal actives, BMR, TDEE, balance, budget, pas. Endpoint principal des graphiques. |
GET /health/goal |
Objectif : poids actuel/cible, progrès %, budget du jour, date projetée, série de projection. |
6.2 vape (préfixe /api/vape)
Modèles :
| Table | Colonnes clés |
|---|---|
vape_cost_models |
valid_from: date, prix du DIY : base_price_eur: Numeric(8,2) + base_volume_ml: float (base PG/VG), booster_price_eur + booster_volume_ml + booster_strength_mg_ml: float, aroma_price_eur + aroma_volume_ml, aroma_rate_pct: float, target_nicotine_mg_ml: float, coil_price_eur: Numeric(8,2), coil_pack_size: int — historisé : le modèle actif à une date D est celui au valid_from le plus récent ≤ D. |
vape_daily_logs |
day: date (jour local), eliquid_ml: float, notes: str | None + SourceMixin ; unique (user_id, day, source) ; upsert-remplacement. |
coil_changes |
changed_at: datetime(tz), coil_model: str | None, notes: str | None + SourceMixin. |
smoking_baselines |
user_id (unique), cigarettes_per_day: float, pack_price_eur: Numeric(6,2), pack_size: int (défaut 20), quit_date: date. |
Calculs (calculations.py) :
- Coût du e-liquide par ml (pour un batch théorique de 1 000 ml au taux cible
nmg/ml) :booster_ml = 1000 * n / booster_strength_mg_ml;aroma_ml = 1000 * aroma_rate_pct / 100;base_ml = 1000 − booster_ml − aroma_ml;cost_1000 = base_ml * (base_price/base_volume) + booster_ml * (booster_price/booster_volume) + aroma_ml * (aroma_price/aroma_volume);cost_per_ml = cost_1000 / 1000. - Nicotine quotidienne (mg) =
eliquid_ml * target_nicotine_mg_ml(modèle actif du jour). - Durée de vie moyenne d'une résistance = moyenne des intervalles entre
coil_changesconsécutifs (fenêtre : 10 derniers changements) ; coût résistance/jour =(coil_price_eur / coil_pack_size) / durée_moyenne_jours. - Coût vape/jour =
eliquid_ml * cost_per_ml + coût_résistance_jour. - Coût tabac évité/jour =
cigarettes_per_day * pack_price_eur / pack_size. - Économies cumulées depuis
quit_date=Σ_jour (coût_tabac_évité − coût_vape_jour)— les jours sans log de vape comptent le coût moyen des 30 derniers jours loggés (ou 0 si aucun log).
Endpoints : GET/PUT /vape/baseline ; GET/POST /vape/cost-models (liste historisée,
GET /vape/cost-models/current) ; CRUD /vape/logs (+ ?from=&to=) ; CRUD /vape/coils +
GET /vape/coils/stats (durée de vie moyenne, dernier changement, prévision prochain) ;
GET /vape/summary?from=&to= (par jour : ml, mg nicotine, coût) ;
GET /vape/savings (cumul, moyenne/jour, équivalent cigarettes non fumées).
6.3 finance (préfixe /api/finance)
Modèles :
| Table | Colonnes clés |
|---|---|
accounts |
name: str, kind: str ("bank","paypal","cash","other"), currency: str (défaut "EUR"), iban_suffix: str | None |
categories |
name: str, parent_id: FK categories | None, color: str (hex), icon: str | None ; unique (user_id, parent_id, name) |
transactions |
account_id: FK, posted_at: date, amount_cents: int (signé : dépense < 0), currency: str, label_raw: str, label_clean: str (normalisé : majuscules/espaces/numéros repliés), category_id: FK | None, notes: str | None, import_run_id: FK | None + SourceMixin |
category_rules |
pattern: str, match_type: str ("contains" | "regex"), field: str ("label_clean"), category_id: FK, priority: int (petit = prioritaire), is_active: bool |
budgets |
category_id: FK, amount_cents: int (> 0), period: str ("monthly"), starts_month: str "YYYY-MM", ends_month: str | None |
Logique (categorize.py) :
apply_rules(db, user_id, transactions): première règle active qui matche (ordreprioritypuisid) fixecategory_id; ne réécrit jamais une catégorie posée manuellement (une colonnecategory_locked: boolsurtransactions, mise à vrai lors d'une catégorisation manuelle).- Les règles s'appliquent automatiquement à la fin de chaque import et via
POST /finance/rules/apply(re-catégorise tout le non-verrouillé). - Détection de récurrences (calcul à la volée, pas de table) : groupement par
label_clean, ≥ 3 occurrences, montants dans ±10 % de la médiane, intervalle médian ∈ {7±2, 14±3, 30±5, 365±15} jours → renvoie libellé, montant médian, périodicité, dernière occurrence, prochaine échéance estimée.
Endpoints : CRUD /finance/accounts ; GET /finance/transactions (paginé ; filtres
?account_id=&category_id=&from=&to=&q=&uncategorized=true ; tri -posted_at),
POST /finance/transactions (saisie manuelle), PATCH /finance/transactions/{id}
(catégorie → verrouille), DELETE ; CRUD /finance/categories (arbre) ; CRUD /finance/rules +
POST /finance/rules/apply ; CRUD /finance/budgets ;
GET /finance/summary/monthly?from=YYYY-MM&to=YYYY-MM (par mois × catégorie : dépensé, budget,
delta) ; GET /finance/recurring.
Montants : toujours en centimes entiers (amount_cents). Le frontend formate en euros
fr-FR (format.ts::formatEuros(cents)).
7. Frontend — structure React
7.1 Contrat de module — src/types/module.ts
import type { RouteObject } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
export interface NavItem {
path: string; // absolute path, ex: "/sante/poids"
label: string; // French label, ex: "Poids"
icon?: LucideIcon; // lucide-react icon component
order: number; // sort key inside the sidebar section
}
export interface ModuleManifest {
id: string; // English module id, ex: "health" (MUST match folder name)
title: string; // French section title, ex: "Santé"
order: number; // sidebar section order (home=0, health=10, vape=20,
// finance=30, imports=80, settings=90)
routes: RouteObject[]; // mounted as children of the protected AppLayout route
nav: NavItem[]; // sidebar entries (may be empty)
}
7.2 Auto-découverte — src/app/modules.ts
import type { ModuleManifest } from "../types/module";
// Eagerly load every module manifest. A module = src/modules/<name>/index.ts
// whose DEFAULT export is a ModuleManifest. Nobody edits this file.
const files = import.meta.glob<{ default: ModuleManifest }>(
"../modules/*/index.ts",
{ eager: true },
);
export const modules: ModuleManifest[] = Object.values(files)
.map((m) => m.default)
.filter(Boolean)
.sort((a, b) => a.order - b.order);
7.3 Routeur — src/app/router.tsx
export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/setup", element: <SetupPage /> },
{
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
children: [
...modules.flatMap((m) => m.routes),
{ path: "*", element: <NotFoundPage /> },
],
},
]);
ProtectedRoute : au montage, interroge GET /api/auth/status (mise en cache React Query) —
si setup_required → redirection /setup ; sinon si pas de token → /login.
Exemple de manifeste — src/modules/health/index.ts :
import { Activity, Dumbbell, Ruler, Scale, Utensils, HeartPulse } from "lucide-react";
import type { ModuleManifest } from "../../types/module";
// pages imported with React.lazy for code-splitting
const manifest: ModuleManifest = {
id: "health",
title: "Santé",
order: 10,
routes: [
{ path: "/sante", element: <HealthDashboardPage /> },
{ path: "/sante/poids", element: <WeightPage /> },
{ path: "/sante/mesures", element: <MeasurementsPage /> },
{ path: "/sante/activite", element: <ActivityPage /> },
{ path: "/sante/entrainements", element: <WorkoutsPage /> },
{ path: "/sante/nutrition", element: <NutritionPage /> },
],
nav: [
{ path: "/sante", label: "Tableau de bord", icon: HeartPulse, order: 0 },
{ path: "/sante/poids", label: "Poids", icon: Scale, order: 1 },
{ path: "/sante/mesures", label: "Mensurations", icon: Ruler, order: 2 },
{ path: "/sante/activite", label: "Activité", icon: Activity, order: 3 },
{ path: "/sante/entrainements", label: "Entraînements", icon: Dumbbell, order: 4 },
{ path: "/sante/nutrition", label: "Nutrition", icon: Utensils, order: 5 },
],
};
export default manifest;
La Sidebar itère modules : titre de section = title, entrées = nav triées par order.
URLs français (visibles par l'utilisateur), ids/fichiers anglais.
7.4 Client API — src/lib/api.ts
export interface ApiErrorShape {
error: { code: string; message: string; details: Record<string, unknown> };
}
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details: Record<string, unknown> = {},
) { super(message); }
}
const TOKEN_KEY = "lifetrack.token";
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (t: string | null) =>
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`/api${path}`, { ...init, headers });
if (res.status === 401) {
setToken(null);
window.location.assign("/login");
throw new ApiError(401, "unauthorized", "Session expirée, veuillez vous reconnecter.");
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as ApiErrorShape | null;
throw new ApiError(
res.status,
body?.error.code ?? "unknown_error",
body?.error.message ?? "Une erreur est survenue.",
body?.error.details ?? {},
);
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
React Query : queryClient unique (staleTime: 30_000, retry: 1). Chaque module définit ses
hooks dans api.ts du module :
// src/modules/health/api.ts
export const healthKeys = {
weights: (p?: object) => ["health", "weights", p ?? {}] as const,
summary: (p: object) => ["health", "summary", p] as const,
};
export function useWeights(params: { page?: number; from?: string; to?: string }) {
return useQuery({
queryKey: healthKeys.weights(params),
queryFn: () => api<Page<WeightRead>>(`/health/weights?${qs(params)}`),
});
}
Convention de clés : [moduleId, resource, params]. Les mutations invalident
[moduleId, resource].
7.5 Thème sombre Tailwind
- Tailwind v3.4,
darkMode: "class"; la classedarkest posée sur<html>dansindex.html(thème sombre par défaut et unique en v1). - Palette : fond
slate-950, surfacesslate-900, borduresslate-800, texteslate-100(secondaireslate-400), accent principalemerald-500, accents module : healthemerald, vapeviolet, financesky, alertesrose-500/amber-400. styles/index.css: directives@tailwind, variables CSS--color-accentpar module optionnelles, scrollbars fines,font-family: Inter, system-ui, sans-serif(Inter en fichier localpublic/fonts/, pas de CDN).
7.6 Wrapper ECharts — src/components/charts/EChart.tsx
- Imports tree-shakés :
echarts/core+LineChart, BarChart, PieChart, ScatterChart, HeatmapChart+ composantsGridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkLineComponent+CanvasRenderer;echarts.use([...])une fois danstheme.ts. theme.ts:echarts.registerTheme("lifetrack-dark", {...})aligné sur la palette Tailwind.- Composant : props
{ option: EChartsOption; height?: number | string; loading?: boolean; onEvents?: Record<string, (p: unknown) => void> }; init au montage avec le thème,ResizeObserversur le conteneur →chart.resize(),chart.setOption(option, { notMerge: true })sur changement,chart.dispose()au démontage. - Formatage des axes/tooltips en
fr-FRvialib/format.ts(ex :1 234,5 kcal,86,4 kg,12,50 €) — jamais de format anglais.
7.7 Écrans clés v1 (rappel produit)
- Accueil (
/) : widgets — poids actuel + tendance 30 j, budget kcal du jour et reste, balance énergétique de la veille, économies vape cumulées, dépenses du mois vs budgets. - Santé : courbe de poids + objectif + projection (markLine cible), journal du jour (kcal in/out), historique activité (barres pas/kcal), CRUD listes.
- Vape : conso ml/jour (barres), nicotine mg/jour, coût/jour, compteur d'économies (« Vous avez économisé X € depuis le DD/MM/YYYY »), gestion résistances, formulaire modèle de coût.
- Finances : liste transactions avec filtres + édition de catégorie inline, donut dépenses par catégorie du mois, barres budget vs réel, page récurrences.
- Imports : zone de dépôt de fichier + sélection du profil de source (liste depuis
/imports/sources, option « Détection automatique »), tableau des runs avec compteurs (insérés / doublons / erreurs) et détail des erreurs. - Paramètres : profil utilisateur, changement de mot de passe, clés d'appareil (création avec affichage unique de la clé, révocation), profil santé, baseline tabac.
8. Conventions d'API
8.1 Généralités
- Préfixe global
/api(posé par le chargeur) ; préfixe module/{module}; ressources au pluriel anglais (/api/health/weights). OpenAPI :/api/docs,/api/openapi.json; chaque routeur portetags=[module](lessummaryd'endpoints peuvent être en français). - JSON uniquement (
application/json), sauf upload (multipart/form-data). - Verbes :
GETlecture,POSTcréation/actions,PATCHmise à jour partielle,PUTremplacement de singleton (profil),DELETEsuppression →204 No Content. - Création →
201avec l'objet créé (schéma*Read).
8.2 Pagination, tri, filtres
- Listes :
?page=(défaut 1) et?page_size=(défaut 50, max 200) ; réponse{"items": [...], "total": n, "page": p, "page_size": s}. - Tri :
?sort=champou?sort=-champ(desc) ; champs autorisés listés par endpoint, défaut documenté (généralement date desc). - Filtres temporels :
?from=/?to=inclusifs —date(YYYY-MM-DD) pour les ressources à jour local, datetime ISO pour les ressources horodatées.
8.3 Forme d'erreur (unique)
{ "error": { "code": "not_found", "message": "Transaction introuvable.", "details": {} } }
code: identifiant machinesnake_casestable ;message: phrase française affichable telle quelle ;details: objet libre (erreurs de validation, index de ligne…).- Statuts : 400
bad_request, 401unauthorized, 403forbidden, 404not_found, 409conflict, 413payload_too_large, 422validation_error, 500internal_error(message générique, détails loggés côté serveur uniquement).
8.4 Dates, fuseaux, unités, monnaie
- Datetimes API : ISO 8601 UTC suffixe
Zen entrée/sortie ; entrée avec offset acceptée et convertie en UTC ; entrée naïve refusée (422). - Dates « jour local » :
YYYY-MM-DD. - Endpoints d'agrégation journalière : paramètre
?tz=(défautEurope/Parisvia settings) — le regroupement par jour se fait dans ce fuseau. - Unités canoniques stockées : kg, cm, ml, mg, kcal, mètres, secondes, centimes d'euro. Toute conversion d'affichage est côté frontend.
9. Docker et déploiement
9.1 docker/api.Dockerfile (multi-étapes)
FROM python:3.12-slim AS builder
WORKDIR /build
COPY apps/api/requirements.txt .
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /srv
COPY --from=builder /opt/venv /opt/venv
COPY apps/api/app ./app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
9.2 docker/web.Dockerfile
FROM node:20-alpine AS build
WORKDIR /build
COPY apps/web/package*.json ./
RUN npm ci
COPY apps/web .
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /build/dist /usr/share/nginx/html
EXPOSE 80
9.3 docker/nginx.conf
server {
listen 80;
server_name _;
client_max_body_size 25m; # uploads d'imports
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8000; # no trailing slash: /api prefix is preserved
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # synchronous imports can take a while
}
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
}
9.4 docker-compose.yml (production maison)
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: docker/api.Dockerfile
restart: unless-stopped
environment:
LIFETRACK_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
LIFETRACK_JWT_SECRET: ${LIFETRACK_JWT_SECRET}
LIFETRACK_TIMEZONE: ${LIFETRACK_TIMEZONE:-Europe/Paris}
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: docker/web.Dockerfile
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:80"
depends_on:
- api
volumes:
pgdata:
Mode dev : docker compose up postgres seulement, puis en local :
uvicorn app.main:app --reload --port 8000 (cwd apps/api, .env à la racine du repo copié ou
LIFETRACK_DATABASE_URL pointant localhost:5432) et npm run dev (Vite, port 5173).
vite.config.ts proxifie :
server: { proxy: { "/api": "http://localhost:8000" } }
(ainsi aucun besoin de CORS en dev via proxy ; LIFETRACK_CORS_ORIGINS=http://localhost:5173
reste disponible si le proxy n'est pas utilisé). Un docker-compose.dev.yml optionnel monte les
sources et lance uvicorn --reload / vite dans des conteneurs pour ceux qui préfèrent.
9.5 .env.example (racine)
# PostgreSQL
POSTGRES_USER=lifetrack
POSTGRES_PASSWORD=change-me
POSTGRES_DB=lifetrack
# API
LIFETRACK_JWT_SECRET=generate-a-long-random-string # ex: openssl rand -hex 32
LIFETRACK_TIMEZONE=Europe/Paris
# Dev only (Vite without proxy): comma-separated origins
# LIFETRACK_CORS_ORIGINS=["http://localhost:5173"]
# Web
WEB_PORT=80
9.6 Dépendances
apps/api/requirements.txt :
fastapi>=0.111
uvicorn[standard]>=0.30
sqlalchemy>=2.0.30
psycopg[binary]>=3.1
pydantic>=2.7
pydantic-settings>=2.2
pwdlib[argon2]>=0.2
PyJWT>=2.8
python-multipart>=0.0.9
ofxparse>=0.21
requirements-dev.txt : pytest, pytest-cov, httpx, ruff.
apps/web/package.json (principales) : react, react-dom, react-router-dom@6,
@tanstack/react-query@5, echarts, lucide-react, tailwindcss@3, typescript, vite,
@vitejs/plugin-react.
9.7 Découpage du travail en chantiers parallèles
- Socle backend :
core/*, moduleauth,main.py, Docker/compose — bloquant pour le reste. - Socle frontend : coquille (
app/*,lib/*,components/*, login/setup) — bloquant côté web. - Ensuite en parallèle, un agent par module :
health(API+web),vape(API+web),finance(API+web),imports(framework §5 + API + web). Grâce à l'auto-découverte, aucun conflit de fichier entre chantiers 3+.
10. CONVENTIONS (à extraire verbatim dans CONVENTIONS.md)
Règles obligatoires pour tout développeur (humain ou agent) ajoutant ou modifiant un module LifeTrack. En cas de doute, ce document fait foi.
C1. Langues
- Code (identifiants, fichiers, tables, colonnes, routes API, commentaires, messages de commit) : anglais.
- UI et messages d'erreur destinés à l'utilisateur (
AppError(message=...), labels,strings.ts, docs utilisateur) : français, ponctuation française correcte, pas d'anglicismes gratuits. Formats d'affichagefr-FR(virgule décimale, espace insécable des milliers,€après le montant).
C2. Créer un module backend
- Créer
apps/api/app/modules/<name>/avec__init__.py(vide) et obligatoirementrouter.pyexposantrouter = APIRouter(prefix="/<name>", tags=["<name>"]). Fichiers standard :models.py,schemas.py,service.py; optionnels :importers.py,ingest.py,calculations.py. - Ne jamais modifier
main.py,module_loader.py, ni le module d'un autre chantier. L'enregistrement est automatique (pkgutil) : routers montés sous/api,models.py/importers.py/ingest.pyimportés au démarrage pourcreate_allet les registres. models.py: style SQLAlchemy 2.0 typé (Mapped/mapped_column), héritageclass X(TimestampMixin, Base); noms de tablessnake_casepluriel ; toute table métier porteuser_id = mapped_column(ForeignKey("users.id"), index=True); datetimes enDateTime(timezone=True)et valeurs UTC ; colonnes « jour local » enDate; poids kg, longueurs cm ou m (distances), volumes ml, énergie kcal, durées secondes, argent en centimes (int) ouNumericpour les prix unitaires.- Table alimentée par import/ingestion → ajouter
SourceMixinet les deux contraintes uniques :(user_id, source, external_id)et(user_id, content_hash)(nomsuq_<table>_external,uq_<table>_hash). Agrégats journaliers → clé(user_id, day, source)+ upsertON CONFLICT DO UPDATE. schemas.py: Pydantic v2 ; suffixesXxxCreate,XxxUpdate(champs optionnels),XxxRead(avecmodel_config = ConfigDict(from_attributes=True)) ; jamais de modèle ORM retourné sans schémaRead.service.py: logique métier ; signaturedef fn(db: Session, user_id: int, ...). Les routeurs restent minces (dépendances + appel service + schéma de réponse). Le service lève les sous-classes d'AppError(NotFoundError,ConflictError,DomainValidationError…) avec message français ; interdit de leverHTTPExceptiondans un module.- Sécurité : tout endpoint (hors
authpublic ethealthz) dépend deget_current_useret filtre systématiquement paruser.id. Endpoints d'ingestion : dépendanceget_ingest_identity("ingest:<domain>"). - Pagination : toute liste utilise
PageParams/Page[T]/paginatedecore.pagination— pas de pagination maison. Tri via?sort=(préfixe-= desc), champs autorisés explicites. - Requêtes :
select()SQLAlchemy 2.0 uniquement (pas desession.query). Le routeur/service ne fait pas decommitpartiel par ligne ; uncommitpar opération logique (leget_dbfournit la session, le service commit).
C3. Ajouter un importeur de fichier
- Dans
importers.pydu module métier concerné, sous-classerBaseImporter(app.core.importing.base) et décorer avec@register_importer. - Renseigner
id(snake_case unique, suffixe format :_csv,_ofx),label(français, affiché dans l'UI),domain,accepted_extensions. sniff()ne lève jamais et reste bon marché (extension + en-têtes dans les 4096 premiers octets).parse()gère les encodagesutf-8-sigpuiscp1252et les CSV;à décimale virgule ; il produit desNormalizedRecorden unités canoniques avecexternal_idsi la source en fournit un, sinondedupe_fieldspertinents (ex :("posted_at", "amount_cents", "label_raw")).upsert()retourneINSERTED/UPDATED/DUPLICATE— la détection de doublon se fait par lookup sur les contraintes du C2.4 (pas par try/except d'IntegrityError en boucle).- Ne pas créer d'endpoint d'upload :
POST /api/importsdu moduleimportssert toutes les sources.
C4. Ajouter un handler d'ingestion JSON
Dans ingest.py du module : sous-classer BaseIngestHandler, décorer
@register_ingest_handler, déclarer domain et record_types, implémenter
apply() (mêmes règles de dédup que C3.4). Le endpoint POST /api/ingest/{domain} existe déjà ;
il exige le scope ingest:<domain> (ou ingest:*) pour les clés d'appareil.
C5. Créer un module frontend
- Créer
apps/web/src/modules/<name>/avecindex.tsdont l'export default est unModuleManifest(src/types/module.ts) :id= nom du dossier (anglais),titlefrançais,orderréservé (home 0, health 10, vape 20, finance 30, imports 80, settings 90 ; nouveaux modules : dizaine libre),routes(chemins absolus, slugs français :/sante/...,/vape/...,/finances/...),nav(labels français + icônelucide-react). - Ne jamais modifier
router.tsx,modules.ts,Sidebar.tsx: la découverteimport.meta.globest automatique. Ajouter une entrée de nav = ajouter un élément au tableaunavdu manifeste de son module. - Fichiers standard du module :
api.ts(hooks React Query + types TS des schémas API),strings.ts(chaînes françaises du module, export d'un objet constant — pas de français en dur éparpillé dans le JSX pour les libellés réutilisés),pages/,components/. - Données : toujours via les hooks React Query de
api.tsdu module, qui appellent le wrapperapi()desrc/lib/api.ts(jamaisfetchdirect). Clés de requête[moduleId, resource, params]; toute mutation invalide[moduleId, resource]. - Graphiques : exclusivement via
<EChart option={...}/>(src/components/charts/EChart.tsx) et le thèmelifetrack-dark; pas d'accès direct àecharts.initdans les pages ; formats d'axes/tooltips viasrc/lib/format.ts. - UI : composants partagés de
src/components/ui/d'abord ; classes Tailwind (palette sombre du §7.5) ; pas de CSS externe, pas de CDN, pas de nouvelle dépendance sans l'ajouter àpackage.jsondu repo. - Pages : nom
XxxPage.tsx, chargées viaReact.lazydans le manifeste ; états vide/chargement/erreur systématiques (EmptyState,Spinner, message d'ApiError.message).
C6. API — rappels contractuels
- Préfixe
/api/<module>; ressources au pluriel anglais ;GETliste paginée (Page[T]),POSTcréation (201),PATCHpartiel,PUTsingleton,DELETE→204. - Forme d'erreur unique
{"error": {"code", "message", "details"}}—codesnake_case stable,messageen français. - Datetimes : UTC ISO 8601 (
Z) ; jours locaux :YYYY-MM-DD; agrégations journalières : paramètre?tz=(défautEurope/Paris). - Filtres temporels :
?from=/?to=inclusifs.
C7. Qualité
- Python :
ruff(lint + format), type hints partout, pas d'import inutilisé ; testspytestdansapps/api/app/tests/(au minimum : contrat du routeur du module + dédup des importeurs). - TypeScript :
strict: true, pas deanynon justifié ; buildnpm run buildsans erreur. - Aucune dépendance réseau à l'exécution côté web (fonts/icônes/librairies embarquées).
- Secrets uniquement via variables d'environnement ; rien de sensible commité (
.envest git-ignoré,.env.exampledocumente).
Fin du document — version 1.0, 2026-08-13.