Files
lifetrack/CONVENTIONS.md
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

134 lines
8.6 KiB
Markdown

# 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, `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).
### 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`.)