# 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//` est découvert par itération `pkgutil` ; frontend : chaque module sous `src/modules//` 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, 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 + │ │ │ ├── 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__external"), UniqueConstraint("user_id", "content_hash", name="uq_
_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//` 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__` où `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 (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:` (`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é` où `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//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: }, { path: "/setup", element: }, { element: , children: [ ...modules.flatMap((m) => m.routes), { path: "*", element: }, ], }, ]); ``` `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: }, { path: "/sante/poids", element: }, { path: "/sante/mesures", element: }, { path: "/sante/activite", element: }, { path: "/sante/entrainements", element: }, { path: "/sante/nutrition", element: }, ], 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 }; } export class ApiError extends Error { constructor( public status: number, public code: string, message: string, public details: Record = {}, ) { 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(path: string, init: RequestInit = {}): Promise { 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>(`/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 `` 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 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//` avec `__init__.py` (vide) et **obligatoirement** `router.py` exposant `router = APIRouter(prefix="/", tags=[""])`. 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_
_external`, `uq_
_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:")`. 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:` (ou `ingest:*`) pour les clés d'appareil. ### C5. Créer un module frontend 1. Créer `apps/web/src/modules//` 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 `` (`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/` ; 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.*