Files
lifetrack/CONVENTIONS.md
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

8.6 KiB

LifeTrack — CONVENTIONS

Extrait de docs/design/architecture.md §10 — règles obligatoires pour tout développeur (humain ou agent).

C1. Langues

  • Code (identifiants, fichiers, tables, colonnes, routes API, commentaires, messages de commit) : anglais.
  • UI et messages d'erreur destinés à l'utilisateur (AppError(message=...), labels, strings.ts, docs utilisateur) : français, ponctuation française correcte, pas d'anglicismes gratuits. Formats d'affichage fr-FR (virgule décimale, espace insécable des milliers, après le montant).

C2. Créer un module backend

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

C3. Ajouter un importeur de fichier

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

C4. Ajouter un handler d'ingestion JSON

Dans ingest.py du module : sous-classer BaseIngestHandler, décorer @register_ingest_handler, déclarer domain et record_types, implémenter apply() (mêmes règles de dédup que C3.4). Le endpoint POST /api/ingest/{domain} existe déjà ; il exige le scope ingest:<domain> (ou ingest:*) pour les clés d'appareil.

C5. Créer un module frontend

  1. Créer apps/web/src/modules/<name>/ avec index.ts dont l'export default est un ModuleManifest (src/types/module.ts) : id = nom du dossier (anglais), title français, order réservé (home 0, health 10, vape 20, finance 30, imports 80, settings 90 ; nouveaux modules : dizaine libre), routes (chemins absolus, slugs français : /sante/..., /vape/..., /finances/...), nav (labels français + icône lucide-react).
  2. Ne jamais modifier router.tsx, modules.ts, Sidebar.tsx : la découverte import.meta.glob est automatique. Ajouter une entrée de nav = ajouter un élément au tableau nav du manifeste de son module.
  3. Fichiers standard du module : api.ts (hooks React Query + types TS des schémas API), strings.ts (chaînes françaises du module, export d'un objet constant — pas de français en dur éparpillé dans le JSX pour les libellés réutilisés), pages/, components/.
  4. Données : toujours via les hooks React Query de api.ts du module, qui appellent le wrapper api() de src/lib/api.ts (jamais fetch direct). Clés de requête [moduleId, resource, params] ; toute mutation invalide [moduleId, resource].
  5. Graphiques : exclusivement via <EChart option={...}/> (src/components/charts/EChart.tsx) et le thème lifetrack-dark ; pas d'accès direct à echarts.init dans les pages ; formats d'axes/tooltips via src/lib/format.ts.
  6. UI : composants partagés de src/components/ui/ d'abord ; classes Tailwind (palette sombre du §7.5) ; pas de CSS externe, pas de CDN, pas de nouvelle dépendance sans l'ajouter à package.json du repo.
  7. Pages : nom XxxPage.tsx, chargées via React.lazy dans le manifeste ; états vide/chargement/erreur systématiques (EmptyState, Spinner, message d'ApiError.message).

C6. API — rappels contractuels

  • Préfixe /api/<module> ; ressources au pluriel anglais ; GET liste paginée (Page[T]), POST création (201), PATCH partiel, PUT singleton, DELETE204.
  • Forme d'erreur unique {"error": {"code", "message", "details"}}code snake_case stable, message en français.
  • Datetimes : UTC ISO 8601 (Z) ; jours locaux : YYYY-MM-DD ; agrégations journalières : paramètre ?tz= (défaut Europe/Paris).
  • Filtres temporels : ?from=/?to= inclusifs.

C7. Qualité

  • Python : ruff (lint + format), type hints partout, pas d'import inutilisé ; tests pytest dans apps/api/app/tests/ (au minimum : contrat du routeur du module + dédup des importeurs).
  • TypeScript : strict: true, pas de any non justifié ; build npm run build sans erreur.
  • Aucune dépendance réseau à l'exécution côté web (fonts/icônes/librairies embarquées).
  • Secrets uniquement via variables d'environnement ; rien de sensible commité (.env est git-ignoré, .env.example documente).

C8. Portabilité des types (tests SQLite)

La suite pytest s'exécute sur SQLite en mémoire (PostgreSQL en production) : tous les modèles doivent donc utiliser des types de colonnes portables.

  1. Énumérations : sa.Enum(..., native_enum=False) — jamais d'ENUM natif PostgreSQL.
  2. UUID : sa.Uuid (type générique SQLAlchemy), pas postgresql.UUID.
  3. JSON : sa.JSON().with_variant(postgresql.JSONB, "postgresql") — utiliser l'alias prêt à l'emploi JSONB_V exporté par app/core/mixins.py (JSON simple sous SQLite, JSONB sous PostgreSQL).
  4. Index uniques partiels : déclarer les deux clauses sqlite_where et postgresql_where (mêmes expressions) ; jamais postgresql_nulls_not_distinct.
  5. Rollback centralisé des imports : toute table portant une colonne référençant import_runs.id doit déclarer la clé étrangère avec ondelete="CASCADE" (ForeignKey("import_runs.id", ondelete="CASCADE")) — c'est ce qui permet à DELETE /api/imports/{id} de supprimer les lignes importées d'un lot. (Sous SQLite, le moteur active PRAGMA foreign_keys=ON automatiquement — voir app/core/database.py.)