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>
8.6 KiB
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'affichagefr-FR(virgule décimale, espace insécable des milliers,€après le montant).
C2. Créer un module backend
- Créer
apps/api/app/modules/<name>/avec__init__.py(vide) et obligatoirementrouter.pyexposantrouter = APIRouter(prefix="/<name>", tags=["<name>"]). Fichiers standard :models.py,schemas.py,service.py; optionnels :importers.py,ingest.py,calculations.py. - 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.pyimportés au démarrage pourcreate_allet les registres. models.py: style SQLAlchemy 2.0 typé (Mapped/mapped_column), héritageclass X(TimestampMixin, Base); noms de tablessnake_casepluriel ; toute table métier porteuser_id = mapped_column(ForeignKey("users.id"), index=True); datetimes enDateTime(timezone=True)et valeurs UTC ; colonnes « jour local » enDate; poids kg, longueurs cm ou m (distances), volumes ml, énergie kcal, durées secondes, argent en centimes (int) ouNumericpour les prix unitaires.- Table alimentée par import/ingestion → ajouter
SourceMixinet les deux contraintes uniques :(user_id, source, external_id)et(user_id, content_hash)(nomsuq_<table>_external,uq_<table>_hash). Agrégats journaliers → clé(user_id, day, source)+ upsertON CONFLICT DO UPDATE. schemas.py: Pydantic v2 ; suffixesXxxCreate,XxxUpdate(champs optionnels),XxxRead(avecmodel_config = ConfigDict(from_attributes=True)) ; jamais de modèle ORM retourné sans schémaRead.service.py: logique métier ; signaturedef 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 leverHTTPExceptiondans un module.- Sécurité : tout endpoint (hors
authpublic ethealthz) dépend deget_current_useret filtre systématiquement paruser.id. Endpoints d'ingestion : dépendanceget_ingest_identity("ingest:<domain>"). - Pagination : toute liste utilise
PageParams/Page[T]/paginatedecore.pagination— pas de pagination maison. Tri via?sort=(préfixe-= desc), champs autorisés explicites. - Requêtes :
select()SQLAlchemy 2.0 uniquement (pas desession.query). Le routeur/service ne fait pas decommitpartiel par ligne ; uncommitpar opération logique (leget_dbfournit la session, le service commit).
C3. Ajouter un importeur de fichier
- Dans
importers.pydu module métier concerné, sous-classerBaseImporter(app.core.importing.base) et décorer avec@register_importer. - Renseigner
id(snake_case unique, suffixe format :_csv,_ofx),label(français, affiché dans l'UI),domain,accepted_extensions. sniff()ne lève jamais et reste bon marché (extension + en-têtes dans les 4096 premiers octets).parse()gère les encodagesutf-8-sigpuiscp1252et les CSV;à décimale virgule ; il produit desNormalizedRecorden unités canoniques avecexternal_idsi la source en fournit un, sinondedupe_fieldspertinents (ex :("posted_at", "amount_cents", "label_raw")).upsert()retourneINSERTED/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).- Ne pas créer d'endpoint d'upload :
POST /api/importsdu moduleimportssert 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
- Créer
apps/web/src/modules/<name>/avecindex.tsdont l'export default est unModuleManifest(src/types/module.ts) :id= nom du dossier (anglais),titlefrançais,orderré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ônelucide-react). - Ne jamais modifier
router.tsx,modules.ts,Sidebar.tsx: la découverteimport.meta.globest automatique. Ajouter une entrée de nav = ajouter un élément au tableaunavdu manifeste de son module. - 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/. - Données : toujours via les hooks React Query de
api.tsdu module, qui appellent le wrapperapi()desrc/lib/api.ts(jamaisfetchdirect). Clés de requête[moduleId, resource, params]; toute mutation invalide[moduleId, resource]. - Graphiques : exclusivement via
<EChart option={...}/>(src/components/charts/EChart.tsx) et le thèmelifetrack-dark; pas d'accès direct àecharts.initdans les pages ; formats d'axes/tooltips viasrc/lib/format.ts. - 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.jsondu repo. - Pages : nom
XxxPage.tsx, chargées viaReact.lazydans 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 ;GETliste paginée (Page[T]),POSTcréation (201),PATCHpartiel,PUTsingleton,DELETE→204. - Forme d'erreur unique
{"error": {"code", "message", "details"}}—codesnake_case stable,messageen français. - Datetimes : UTC ISO 8601 (
Z) ; jours locaux :YYYY-MM-DD; agrégations journalières : paramètre?tz=(défautEurope/Paris). - Filtres temporels :
?from=/?to=inclusifs.
C7. Qualité
- Python :
ruff(lint + format), type hints partout, pas d'import inutilisé ; testspytestdansapps/api/app/tests/(au minimum : contrat du routeur du module + dédup des importeurs). - TypeScript :
strict: true, pas deanynon justifié ; buildnpm run buildsans 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é (
.envest git-ignoré,.env.exampledocumente).
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.
- Énumérations :
sa.Enum(..., native_enum=False)— jamais d'ENUMnatif PostgreSQL. - UUID :
sa.Uuid(type générique SQLAlchemy), paspostgresql.UUID. - JSON :
sa.JSON().with_variant(postgresql.JSONB, "postgresql")— utiliser l'alias prêt à l'emploiJSONB_Vexporté parapp/core/mixins.py(JSON simple sous SQLite, JSONB sous PostgreSQL). - Index uniques partiels : déclarer les deux clauses
sqlite_whereetpostgresql_where(mêmes expressions) ; jamaispostgresql_nulls_not_distinct. - Rollback centralisé des imports : toute table portant une colonne référençant
import_runs.iddoit déclarer la clé étrangère avecondelete="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 activePRAGMA foreign_keys=ONautomatiquement — voirapp/core/database.py.)