Files
lifetrack/docs/design/architecture.md
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

70 KiB
Raw Blame History

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 :

  1. Modularité par auto-découverte — backend : chaque module sous app/modules/<name>/ est découvert par itération pkgutil ; frontend : chaque module sous src/modules/<name>/ est découvert par import.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.
  2. 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.
  3. Temps — stockage en UTC (colonnes TIMESTAMPTZ), affichage et agrégations journalières dans le fuseau Europe/Paris (paramétrable via ?tz=). Les totaux « par jour » fournis par les sources (pas de timestamp) sont stockés dans des colonnes DATE représentant le jour local.
  4. 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 : pwdlib avec recommandation argon2 — password_hasher = PasswordHash.recommended() ; hash(pw) / verify(pw, hash).
  • JWT : bibliothèque PyJWT. Claims : sub (str(user.id)), iat, exp. Signé HS256 avec settings.jwt_secret. Fonctions create_access_token(user_id) -> str et decode_access_token(token) -> int (lève UnauthorizedError si invalide/expiré).
  • Clés API d'appareil : format ltk_<prefix>_<secret>prefix = 8 hex aléatoires, secret = secrets.token_urlsafe(32). On stocke key_prefix + sha256(full_key). La clé en clair n'est retournée qu'une seule fois à la création. Fonctions generate_device_key() -> tuple[full_key, prefix, key_hash] et verify_device_key(db, full_key) -> DeviceApiKey (lookup par prefix, comparaison hmac.compare_digest, vérifie revoked_at is None, met à jour last_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 :

  1. Fichiers (CSV/OFX/exports d'apps) → BaseImporter, upload via POST /api/imports.
  2. 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_id fourni par la source → contrainte unique (user_id, source, external_id) ; en collision → DUPLICATE (skip).
    • sinon → content_hash sur dedupe_fields → contrainte (user_id, content_hash) ; en collision → DUPLICATE.
  • Enregistrements « agrégat journalier » (DailyActivity, VapeDailyLog) : clé naturelle (user_id, day, source) avec upsert-remplacement (INSERT … ON CONFLICT DO UPDATE, dialect sqlalchemy.dialects.postgresql.insert) → UPDATED si 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 (sedentary 1.2, light 1.375, moderate 1.55, active 1.725, very_active 1.9). Le poids utilisé est la dernière pesée connue.
  • Balance énergétique du jour = kcal ingérées (food_entries) (BMR + calories actives où 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 champ calories_kcal comme actives — convention du connecteur).
  • Budget calorique quotidien = TDEE déficit_visédéficit_visé = (poids_actuel target_weight_kg) * 7700 / jours_restants si target_date définie, plafonné à 1 000 kcal/j ; sinon défaut 500 kcal/j ; calorie_budget_override_kcal court-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 n mg/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_changes consé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 (ordre priority puis id) fixe category_id ; ne réécrit jamais une catégorie posée manuellement (une colonne category_locked: bool sur transactions, 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 classe dark est posée sur <html> dans index.html (thème sombre par défaut et unique en v1).
  • Palette : fond slate-950, surfaces slate-900, bordures slate-800, texte slate-100 (secondaire slate-400), accent principal emerald-500, accents module : health emerald, vape violet, finance sky, alertes rose-500 / amber-400.
  • styles/index.css : directives @tailwind, variables CSS --color-accent par module optionnelles, scrollbars fines, font-family: Inter, system-ui, sans-serif (Inter en fichier local public/fonts/, pas de CDN).

7.6 Wrapper ECharts — src/components/charts/EChart.tsx

  • Imports tree-shakés : echarts/core + LineChart, BarChart, PieChart, ScatterChart, HeatmapChart + composants GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkLineComponent + CanvasRenderer ; echarts.use([...]) une fois dans theme.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, ResizeObserver sur le conteneur → chart.resize(), chart.setOption(option, { notMerge: true }) sur changement, chart.dispose() au démontage.
  • Formatage des axes/tooltips en fr-FR via lib/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 porte tags=[module] (les summary d'endpoints peuvent être en français).
  • JSON uniquement (application/json), sauf upload (multipart/form-data).
  • Verbes : GET lecture, POST création/actions, PATCH mise à jour partielle, PUT remplacement de singleton (profil), DELETE suppression → 204 No Content.
  • Création → 201 avec 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=champ ou ?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 machine snake_case stable ; message : phrase française affichable telle quelle ; details : objet libre (erreurs de validation, index de ligne…).
  • Statuts : 400 bad_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict, 413 payload_too_large, 422 validation_error, 500 internal_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 Z en 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éfaut Europe/Paris via 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

  1. Socle backend : core/*, module auth, main.py, Docker/compose — bloquant pour le reste.
  2. Socle frontend : coquille (app/*, lib/*, components/*, login/setup) — bloquant côté web.
  3. 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'affichage fr-FR (virgule décimale, espace insécable des milliers, après le montant).

C2. Créer un module backend

  1. Créer apps/api/app/modules/<name>/ avec __init__.py (vide) et obligatoirement router.py exposant router = APIRouter(prefix="/<name>", tags=["<name>"]). Fichiers standard : models.py, schemas.py, service.py ; optionnels : importers.py, ingest.py, calculations.py.
  2. 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.py importés au démarrage pour create_all et les registres.
  3. models.py : style SQLAlchemy 2.0 typé (Mapped/mapped_column), héritage class X(TimestampMixin, Base) ; noms de tables snake_case pluriel ; toute table métier porte user_id = mapped_column(ForeignKey("users.id"), index=True) ; datetimes en DateTime(timezone=True) et valeurs UTC ; colonnes « jour local » en Date ; poids kg, longueurs cm ou m (distances), volumes ml, énergie kcal, durées secondes, argent en centimes (int) ou Numeric pour les prix unitaires.
  4. Table alimentée par import/ingestion → ajouter SourceMixin et les deux contraintes uniques : (user_id, source, external_id) et (user_id, content_hash) (noms uq_<table>_external, uq_<table>_hash). Agrégats journaliers → clé (user_id, day, source) + upsert ON CONFLICT DO UPDATE.
  5. schemas.py : Pydantic v2 ; suffixes XxxCreate, XxxUpdate (champs optionnels), XxxRead (avec model_config = ConfigDict(from_attributes=True)) ; jamais de modèle ORM retourné sans schéma Read.
  6. service.py : logique métier ; signature def 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 lever HTTPException dans un module.
  7. Sécurité : tout endpoint (hors auth public et healthz) dépend de get_current_user et filtre systématiquement par user.id. Endpoints d'ingestion : dépendance get_ingest_identity("ingest:<domain>").
  8. Pagination : toute liste utilise PageParams/Page[T]/paginate de core.pagination — pas de pagination maison. Tri via ?sort= (préfixe - = desc), champs autorisés explicites.
  9. Requêtes : select() SQLAlchemy 2.0 uniquement (pas de session.query). Le routeur/service ne fait pas de commit partiel par ligne ; un commit par opération logique (le get_db fournit la session, le service commit).

C3. Ajouter un importeur de fichier

  1. Dans importers.py du module métier concerné, sous-classer BaseImporter (app.core.importing.base) et décorer avec @register_importer.
  2. Renseigner id (snake_case unique, suffixe format : _csv, _ofx), label (français, affiché dans l'UI), domain, accepted_extensions.
  3. sniff() ne lève jamais et reste bon marché (extension + en-têtes dans les 4096 premiers octets). parse() gère les encodages utf-8-sig puis cp1252 et les CSV ; à décimale virgule ; il produit des NormalizedRecord en unités canoniques avec external_id si la source en fournit un, sinon dedupe_fields pertinents (ex : ("posted_at", "amount_cents", "label_raw")).
  4. upsert() retourne INSERTED / 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).
  5. Ne pas créer d'endpoint d'upload : POST /api/imports du module imports sert 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

  1. Créer apps/web/src/modules/<name>/ avec index.ts dont l'export default est un ModuleManifest (src/types/module.ts) : id = nom du dossier (anglais), title français, order ré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ône lucide-react).
  2. Ne jamais modifier router.tsx, modules.ts, Sidebar.tsx : la découverte import.meta.glob est automatique. Ajouter une entrée de nav = ajouter un élément au tableau nav du manifeste de son module.
  3. 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/.
  4. Données : toujours via les hooks React Query de api.ts du module, qui appellent le wrapper api() de src/lib/api.ts (jamais fetch direct). Clés de requête [moduleId, resource, params] ; toute mutation invalide [moduleId, resource].
  5. Graphiques : exclusivement via <EChart option={...}/> (src/components/charts/EChart.tsx) et le thème lifetrack-dark ; pas d'accès direct à echarts.init dans les pages ; formats d'axes/tooltips via src/lib/format.ts.
  6. 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.json du repo.
  7. Pages : nom XxxPage.tsx, chargées via React.lazy dans 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 ; GET liste paginée (Page[T]), POST création (201), PATCH partiel, PUT singleton, DELETE204.
  • Forme d'erreur unique {"error": {"code", "message", "details"}}code snake_case stable, message en français.
  • Datetimes : UTC ISO 8601 (Z) ; jours locaux : YYYY-MM-DD ; agrégations journalières : paramètre ?tz= (défaut Europe/Paris).
  • Filtres temporels : ?from=/?to= inclusifs.

C7. Qualité

  • Python : ruff (lint + format), type hints partout, pas d'import inutilisé ; tests pytest dans apps/api/app/tests/ (au minimum : contrat du routeur du module + dédup des importeurs).
  • TypeScript : strict: true, pas de any non justifié ; build npm run build sans 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é (.env est git-ignoré, .env.example documente).

Fin du document — version 1.0, 2026-08-13.