Files
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

1585 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
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` :
```python
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.
```python
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`
```python
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` :
```python
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__` :
```python
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`.
```python
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` :
```python
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).
```python
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`
```python
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 :
```python
@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`
```python
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) :
```python
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`
```python
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`
```python
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`
```python
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` :
```python
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`
```python
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`
```python
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` :
```python
@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) :
```python
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) :
```json
{
"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` :
```json
{
"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`
```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`
```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`
```ts
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` :
```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`
```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 :
```ts
// 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)
```json
{ "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)
```dockerfile
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`
```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`
```nginx
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)
```yaml
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 :
```ts
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)
```env
# 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` :
```text
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, `DELETE` → `204`.
- 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.*